Compare commits
	
		
			4 Commits
		
	
	
		
			secure-clu
			...
			self-daemo
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 64680a6ece | |||
| 4c3fcbc45a | |||
| a41e0d625c | |||
| 377af163f0 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,6 +1,8 @@
 | 
			
		||||
.claude/
 | 
			
		||||
.kvs/
 | 
			
		||||
data/
 | 
			
		||||
data*/
 | 
			
		||||
integration_test/
 | 
			
		||||
*.yaml
 | 
			
		||||
!config.yaml
 | 
			
		||||
kvs
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										76
									
								
								CLAUDE.md
									
									
									
									
									
								
							
							
						
						
									
										76
									
								
								CLAUDE.md
									
									
									
									
									
								
							@@ -10,10 +10,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 | 
			
		||||
go build -o kvs .
 | 
			
		||||
 | 
			
		||||
# Run with default config (auto-generates config.yaml)
 | 
			
		||||
./kvs
 | 
			
		||||
./kvs start config.yaml
 | 
			
		||||
 | 
			
		||||
# Run with custom config
 | 
			
		||||
./kvs /path/to/config.yaml
 | 
			
		||||
./kvs start /path/to/config.yaml
 | 
			
		||||
 | 
			
		||||
# Check running instances
 | 
			
		||||
./kvs status
 | 
			
		||||
 | 
			
		||||
# Stop instance
 | 
			
		||||
./kvs stop config
 | 
			
		||||
 | 
			
		||||
# Run comprehensive integration tests
 | 
			
		||||
./integration_test.sh
 | 
			
		||||
@@ -25,6 +31,32 @@ go run test_conflict.go data1 data2
 | 
			
		||||
go build -o kvs . && ./integration_test.sh
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Process Management Commands
 | 
			
		||||
```bash
 | 
			
		||||
# Start as background daemon
 | 
			
		||||
./kvs start <config.yaml>       # .yaml extension optional
 | 
			
		||||
 | 
			
		||||
# Stop daemon
 | 
			
		||||
./kvs stop <config>             # Graceful SIGTERM shutdown
 | 
			
		||||
 | 
			
		||||
# Restart daemon
 | 
			
		||||
./kvs restart <config>          # Stop then start
 | 
			
		||||
 | 
			
		||||
# Show status
 | 
			
		||||
./kvs status                    # All instances
 | 
			
		||||
./kvs status <config>           # Specific instance
 | 
			
		||||
 | 
			
		||||
# Run in foreground (for debugging)
 | 
			
		||||
./kvs <config.yaml>             # Logs to stdout, blocks terminal
 | 
			
		||||
 | 
			
		||||
# View daemon logs
 | 
			
		||||
tail -f ~/.kvs/logs/kvs_<config>.yaml.log
 | 
			
		||||
 | 
			
		||||
# Global state directories
 | 
			
		||||
~/.kvs/pids/                    # PID files (works from any directory)
 | 
			
		||||
~/.kvs/logs/                    # Daemon log files
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Development Workflow
 | 
			
		||||
```bash
 | 
			
		||||
# Format and check code
 | 
			
		||||
@@ -38,11 +70,25 @@ go mod tidy
 | 
			
		||||
go build .
 | 
			
		||||
 | 
			
		||||
# Test specific cluster scenarios
 | 
			
		||||
./kvs node1.yaml &  # Terminal 1
 | 
			
		||||
./kvs node2.yaml &  # Terminal 2
 | 
			
		||||
./kvs start node1.yaml
 | 
			
		||||
./kvs start node2.yaml
 | 
			
		||||
 | 
			
		||||
# Wait for cluster formation
 | 
			
		||||
sleep 5
 | 
			
		||||
 | 
			
		||||
# Test data operations
 | 
			
		||||
curl -X PUT http://localhost:8081/kv/test/data -H "Content-Type: application/json" -d '{"test":"data"}'
 | 
			
		||||
curl http://localhost:8082/kv/test/data  # Should replicate within ~30 seconds
 | 
			
		||||
pkill kvs
 | 
			
		||||
 | 
			
		||||
# Check daemon status
 | 
			
		||||
./kvs status
 | 
			
		||||
 | 
			
		||||
# View logs
 | 
			
		||||
tail -f ~/.kvs/logs/kvs_node1.yaml.log
 | 
			
		||||
 | 
			
		||||
# Cleanup
 | 
			
		||||
./kvs stop node1
 | 
			
		||||
./kvs stop node2
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Architecture Overview
 | 
			
		||||
@@ -58,7 +104,8 @@ KVS is a **distributed, eventually consistent key-value store** built around thr
 | 
			
		||||
 | 
			
		||||
#### Modular Package Design
 | 
			
		||||
- **`auth/`** - Complete JWT authentication system with POSIX-inspired permissions
 | 
			
		||||
- **`cluster/`** - Distributed systems logic (gossip, sync, merkle trees)  
 | 
			
		||||
- **`cluster/`** - Distributed systems logic (gossip, sync, merkle trees)
 | 
			
		||||
- **`daemon/`** - Process management (daemonization, PID files, lifecycle)
 | 
			
		||||
- **`storage/`** - BadgerDB abstraction with compression and revision history
 | 
			
		||||
- **`server/`** - HTTP handlers, routing, and lifecycle management
 | 
			
		||||
- **`features/`** - Utility functions for TTL, rate limiting, tamper logging, backup
 | 
			
		||||
@@ -147,9 +194,18 @@ Creates two BadgerDB instances with intentionally conflicting data (same path, s
 | 
			
		||||
- **Bootstrap sync**: Up to 30 days of historical data for new nodes
 | 
			
		||||
 | 
			
		||||
#### Main Entry Point Flow
 | 
			
		||||
1. `main.go` loads config (auto-generates default if missing)
 | 
			
		||||
2. `server.NewServer()` initializes all subsystems
 | 
			
		||||
3. Graceful shutdown handling with `SIGINT`/`SIGTERM`
 | 
			
		||||
4. All business logic delegated to modular packages
 | 
			
		||||
1. `main.go` parses command-line arguments for subcommands (`start`, `stop`, `status`, `restart`)
 | 
			
		||||
2. For daemon mode: `daemon.Daemonize()` spawns background process and manages PID files
 | 
			
		||||
3. For server mode: loads config (auto-generates default if missing)
 | 
			
		||||
4. `server.NewServer()` initializes all subsystems
 | 
			
		||||
5. Graceful shutdown handling with `SIGINT`/`SIGTERM`
 | 
			
		||||
6. All business logic delegated to modular packages
 | 
			
		||||
 | 
			
		||||
#### Daemon Architecture
 | 
			
		||||
- **PID Management**: Global PID files stored in `~/.kvs/pids/` for cross-directory access
 | 
			
		||||
- **Logging**: Daemon logs written to `~/.kvs/logs/{config-name}.log`
 | 
			
		||||
- **Process Lifecycle**: Spawns detached process via `exec.Command()` with `Setsid: true`
 | 
			
		||||
- **Config Normalization**: Supports both `node1` and `node1.yaml` formats
 | 
			
		||||
- **Stale PID Detection**: Checks process existence via `Signal(0)` before operations
 | 
			
		||||
 | 
			
		||||
This architecture enables easy feature addition, comprehensive testing, and reliable operation in distributed environments while maintaining simplicity for single-node deployments.
 | 
			
		||||
							
								
								
									
										115
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										115
									
								
								README.md
									
									
									
									
									
								
							@@ -69,11 +69,67 @@ go build -o kvs .
 | 
			
		||||
 | 
			
		||||
### Quick Test
 | 
			
		||||
```bash
 | 
			
		||||
# Start standalone node
 | 
			
		||||
./kvs
 | 
			
		||||
# Start standalone node (uses config.yaml if it exists, or creates it)
 | 
			
		||||
./kvs start config.yaml
 | 
			
		||||
 | 
			
		||||
# Test the API
 | 
			
		||||
curl http://localhost:8080/health
 | 
			
		||||
 | 
			
		||||
# Check status
 | 
			
		||||
./kvs status
 | 
			
		||||
 | 
			
		||||
# Stop when done
 | 
			
		||||
./kvs stop config
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 🎮 Process Management
 | 
			
		||||
 | 
			
		||||
KVS includes systemd-style daemon commands for easy process management:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Start as background daemon
 | 
			
		||||
./kvs start config.yaml      # or just: ./kvs start config
 | 
			
		||||
./kvs start node1.yaml       # Start with custom config
 | 
			
		||||
 | 
			
		||||
# Check status
 | 
			
		||||
./kvs status                 # Show all running instances
 | 
			
		||||
./kvs status node1           # Show specific instance
 | 
			
		||||
 | 
			
		||||
# Stop daemon
 | 
			
		||||
./kvs stop node1             # Graceful shutdown
 | 
			
		||||
 | 
			
		||||
# Restart daemon
 | 
			
		||||
./kvs restart node1          # Stop and start
 | 
			
		||||
 | 
			
		||||
# Run in foreground (traditional)
 | 
			
		||||
./kvs node1.yaml             # Logs to stdout
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Daemon Features
 | 
			
		||||
- **Global PID tracking**: PID files stored in `~/.kvs/pids/` (works from any directory)
 | 
			
		||||
- **Automatic logging**: Logs written to `~/.kvs/logs/{config-name}.log`
 | 
			
		||||
- **Flexible naming**: Config extension optional (`node1` or `node1.yaml` both work)
 | 
			
		||||
- **Graceful shutdown**: SIGTERM sent for clean shutdown
 | 
			
		||||
- **Stale PID cleanup**: Automatically detects and cleans dead processes
 | 
			
		||||
- **Multi-instance**: Run multiple KVS instances on same machine
 | 
			
		||||
 | 
			
		||||
### Example Workflow
 | 
			
		||||
```bash
 | 
			
		||||
# Start 3-node cluster as daemons
 | 
			
		||||
./kvs start node1.yaml
 | 
			
		||||
./kvs start node2.yaml
 | 
			
		||||
./kvs start node3.yaml
 | 
			
		||||
 | 
			
		||||
# Check cluster status
 | 
			
		||||
./kvs status
 | 
			
		||||
 | 
			
		||||
# View logs
 | 
			
		||||
tail -f ~/.kvs/logs/kvs_node1.yaml.log
 | 
			
		||||
 | 
			
		||||
# Stop entire cluster
 | 
			
		||||
./kvs stop node1
 | 
			
		||||
./kvs stop node2
 | 
			
		||||
./kvs stop node3
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## ⚙️ Configuration
 | 
			
		||||
@@ -308,17 +364,23 @@ clustering_enabled: true
 | 
			
		||||
 | 
			
		||||
#### Start the Cluster
 | 
			
		||||
```bash
 | 
			
		||||
# Terminal 1
 | 
			
		||||
./kvs node1.yaml
 | 
			
		||||
 | 
			
		||||
# Terminal 2 (wait a few seconds)
 | 
			
		||||
./kvs node2.yaml
 | 
			
		||||
 | 
			
		||||
# Terminal 3 (wait a few seconds)
 | 
			
		||||
./kvs node3.yaml
 | 
			
		||||
# Start as daemons
 | 
			
		||||
./kvs start node1.yaml
 | 
			
		||||
sleep 2
 | 
			
		||||
./kvs start node2.yaml
 | 
			
		||||
sleep 2
 | 
			
		||||
./kvs start node3.yaml
 | 
			
		||||
 | 
			
		||||
# Verify cluster formation
 | 
			
		||||
curl http://localhost:8081/members/  # Should show all 3 nodes
 | 
			
		||||
 | 
			
		||||
# Check daemon status
 | 
			
		||||
./kvs status
 | 
			
		||||
 | 
			
		||||
# Stop cluster when done
 | 
			
		||||
./kvs stop node1
 | 
			
		||||
./kvs stop node2
 | 
			
		||||
./kvs stop node3
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 🔄 How It Works
 | 
			
		||||
@@ -364,9 +426,10 @@ go build -o kvs .
 | 
			
		||||
./integration_test.sh
 | 
			
		||||
 | 
			
		||||
# Manual basic functionality test
 | 
			
		||||
./kvs &
 | 
			
		||||
./kvs start config.yaml
 | 
			
		||||
sleep 2
 | 
			
		||||
curl http://localhost:8080/health
 | 
			
		||||
pkill kvs
 | 
			
		||||
./kvs stop config
 | 
			
		||||
 | 
			
		||||
# Manual cluster test (requires creating configs)
 | 
			
		||||
echo 'node_id: "test1"
 | 
			
		||||
@@ -379,8 +442,9 @@ port: 8082
 | 
			
		||||
seed_nodes: ["127.0.0.1:8081"]
 | 
			
		||||
auth_enabled: false' > test2.yaml
 | 
			
		||||
 | 
			
		||||
./kvs test1.yaml &
 | 
			
		||||
./kvs test2.yaml &
 | 
			
		||||
./kvs start test1.yaml
 | 
			
		||||
sleep 2
 | 
			
		||||
./kvs start test2.yaml
 | 
			
		||||
 | 
			
		||||
# Test data replication (wait for cluster formation)
 | 
			
		||||
sleep 10
 | 
			
		||||
@@ -393,7 +457,8 @@ sleep 30
 | 
			
		||||
curl http://localhost:8082/kv/test/data
 | 
			
		||||
 | 
			
		||||
# Cleanup
 | 
			
		||||
pkill kvs
 | 
			
		||||
./kvs stop test1
 | 
			
		||||
./kvs stop test2
 | 
			
		||||
rm test1.yaml test2.yaml
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@@ -418,17 +483,22 @@ auth_enabled: false
 | 
			
		||||
log_level: "debug"' > conflict2.yaml
 | 
			
		||||
 | 
			
		||||
# Start nodes with conflicting data
 | 
			
		||||
./kvs conflict1.yaml &
 | 
			
		||||
./kvs conflict2.yaml &
 | 
			
		||||
./kvs start conflict1.yaml
 | 
			
		||||
sleep 2
 | 
			
		||||
./kvs start conflict2.yaml
 | 
			
		||||
 | 
			
		||||
# Watch logs for conflict resolution
 | 
			
		||||
tail -f ~/.kvs/logs/kvs_conflict1.yaml.log ~/.kvs/logs/kvs_conflict2.yaml.log &
 | 
			
		||||
 | 
			
		||||
# Both nodes will converge within ~10-30 seconds
 | 
			
		||||
# Check final state
 | 
			
		||||
sleep 30
 | 
			
		||||
curl http://localhost:9111/kv/test/conflict/data
 | 
			
		||||
curl http://localhost:9112/kv/test/conflict/data
 | 
			
		||||
 | 
			
		||||
pkill kvs
 | 
			
		||||
# Cleanup
 | 
			
		||||
./kvs stop conflict1
 | 
			
		||||
./kvs stop conflict2
 | 
			
		||||
rm conflict1.yaml conflict2.yaml
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@@ -474,6 +544,10 @@ kvs/
 | 
			
		||||
├── config/                    # Configuration management
 | 
			
		||||
│   └── config.go             # Config loading & defaults
 | 
			
		||||
│
 | 
			
		||||
├── daemon/                    # Process management
 | 
			
		||||
│   ├── daemonize.go          # Background process spawning
 | 
			
		||||
│   └── pid.go                # PID file management
 | 
			
		||||
│
 | 
			
		||||
├── features/                  # Utility features
 | 
			
		||||
│   ├── auth.go               # Auth utilities
 | 
			
		||||
│   ├── backup.go             # Backup system
 | 
			
		||||
@@ -580,8 +654,9 @@ type StoredValue struct {
 | 
			
		||||
## 🛡️ Production Considerations
 | 
			
		||||
 | 
			
		||||
### Deployment
 | 
			
		||||
- Use systemd or similar for process management
 | 
			
		||||
- Configure log rotation for JSON logs
 | 
			
		||||
- Built-in daemon commands (`start`/`stop`/`restart`/`status`) for process management
 | 
			
		||||
- Alternatively, use systemd or similar for advanced orchestration
 | 
			
		||||
- Logs automatically written to `~/.kvs/logs/` (configure log rotation)
 | 
			
		||||
- Set up monitoring for `/health` endpoint
 | 
			
		||||
- Use reverse proxy (nginx/traefik) for TLS and load balancing
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										34
									
								
								auth/auth.go
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								auth/auth.go
									
									
									
									
									
								
							@@ -198,6 +198,40 @@ func (s *AuthService) CheckResourcePermission(authCtx *AuthContext, resourceKey
 | 
			
		||||
	return CheckPermission(metadata.Permissions, operation, isOwner, isGroupMember)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetResourceMetadata retrieves metadata for a resource
 | 
			
		||||
func (s *AuthService) GetResourceMetadata(resourceKey string) (*types.ResourceMetadata, error) {
 | 
			
		||||
	var metadata types.ResourceMetadata
 | 
			
		||||
 | 
			
		||||
	err := s.db.View(func(txn *badger.Txn) error {
 | 
			
		||||
		item, err := txn.Get([]byte(ResourceMetadataKey(resourceKey)))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return item.Value(func(val []byte) error {
 | 
			
		||||
			return json.Unmarshal(val, &metadata)
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &metadata, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetResourceMetadata stores metadata for a resource
 | 
			
		||||
func (s *AuthService) SetResourceMetadata(resourceKey string, metadata *types.ResourceMetadata) error {
 | 
			
		||||
	metadataBytes, err := json.Marshal(metadata)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to marshal metadata: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return s.db.Update(func(txn *badger.Txn) error {
 | 
			
		||||
		return txn.Set([]byte(ResourceMetadataKey(resourceKey)), metadataBytes)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetAuthContext retrieves auth context from request context
 | 
			
		||||
func GetAuthContext(ctx context.Context) *AuthContext {
 | 
			
		||||
	if authCtx, ok := ctx.Value("auth").(*AuthContext); ok {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								daemon/daemonize.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								daemon/daemonize.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
package daemon
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/exec"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"syscall"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// GetLogFilePath returns the log file path for a given config file
 | 
			
		||||
func GetLogFilePath(configPath string) (string, error) {
 | 
			
		||||
	logDir, err := getLogDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	absConfigPath, err := filepath.Abs(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to get absolute config path: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	basename := filepath.Base(configPath)
 | 
			
		||||
	name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
 | 
			
		||||
	return filepath.Join(logDir, name+".log"), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Daemonize spawns the process as a daemon and returns
 | 
			
		||||
func Daemonize(configPath string) error {
 | 
			
		||||
	// Get absolute path to the current executable
 | 
			
		||||
	executable, err := os.Executable()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get executable path: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get absolute path to config
 | 
			
		||||
	absConfigPath, err := filepath.Abs(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get absolute config path: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if already running
 | 
			
		||||
	_, running, err := ReadPID(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to check if instance is running: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if running {
 | 
			
		||||
		return fmt.Errorf("instance is already running")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Spawn the process in background with --daemon flag
 | 
			
		||||
	cmd := exec.Command(executable, "--daemon", absConfigPath)
 | 
			
		||||
	cmd.SysProcAttr = &syscall.SysProcAttr{
 | 
			
		||||
		Setsid: true, // Create new session
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Redirect stdout/stderr to log file
 | 
			
		||||
	logDir, err := getLogDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to get log directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	if err := os.MkdirAll(logDir, 0755); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to create log directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	basename := filepath.Base(configPath)
 | 
			
		||||
	name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
 | 
			
		||||
	logFile := filepath.Join(logDir, name+".log")
 | 
			
		||||
 | 
			
		||||
	f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to open log file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer f.Close()
 | 
			
		||||
 | 
			
		||||
	cmd.Stdout = f
 | 
			
		||||
	cmd.Stderr = f
 | 
			
		||||
 | 
			
		||||
	if err := cmd.Start(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to start daemon: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Started KVS instance '%s' (PID will be written by daemon)\n", filepath.Base(configPath))
 | 
			
		||||
	fmt.Printf("Logs: %s\n", logFile)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										171
									
								
								daemon/pid.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								daemon/pid.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,171 @@
 | 
			
		||||
package daemon
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"syscall"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// getPIDDir returns the absolute path to the PID directory
 | 
			
		||||
func getPIDDir() (string, error) {
 | 
			
		||||
	homeDir, err := os.UserHomeDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to get user home directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	return filepath.Join(homeDir, ".kvs", "pids"), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getLogDir returns the absolute path to the log directory
 | 
			
		||||
func getLogDir() (string, error) {
 | 
			
		||||
	homeDir, err := os.UserHomeDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("failed to get user home directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	return filepath.Join(homeDir, ".kvs", "logs"), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetPIDFilePath returns the PID file path for a given config file
 | 
			
		||||
func GetPIDFilePath(configPath string) string {
 | 
			
		||||
	pidDir, err := getPIDDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		// Fallback to local directory
 | 
			
		||||
		pidDir = ".kvs/pids"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Extract basename without extension
 | 
			
		||||
	basename := filepath.Base(configPath)
 | 
			
		||||
	name := strings.TrimSuffix(basename, filepath.Ext(basename))
 | 
			
		||||
 | 
			
		||||
	return filepath.Join(pidDir, name+".pid")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// EnsurePIDDir creates the PID directory if it doesn't exist
 | 
			
		||||
func EnsurePIDDir() error {
 | 
			
		||||
	pidDir, err := getPIDDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return os.MkdirAll(pidDir, 0755)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// WritePID writes the current process PID to a file
 | 
			
		||||
func WritePID(configPath string) error {
 | 
			
		||||
	if err := EnsurePIDDir(); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to create PID directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	pidFile := GetPIDFilePath(configPath)
 | 
			
		||||
	pid := os.Getpid()
 | 
			
		||||
 | 
			
		||||
	return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", pid)), 0644)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ReadPID reads the PID from a file and checks if the process is running
 | 
			
		||||
func ReadPID(configPath string) (int, bool, error) {
 | 
			
		||||
	pidFile := GetPIDFilePath(configPath)
 | 
			
		||||
 | 
			
		||||
	data, err := os.ReadFile(pidFile)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if os.IsNotExist(err) {
 | 
			
		||||
			return 0, false, nil
 | 
			
		||||
		}
 | 
			
		||||
		return 0, false, fmt.Errorf("failed to read PID file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	pidStr := strings.TrimSpace(string(data))
 | 
			
		||||
	pid, err := strconv.Atoi(pidStr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, false, fmt.Errorf("invalid PID in file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if process is actually running
 | 
			
		||||
	process, err := os.FindProcess(pid)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return pid, false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Send signal 0 to check if process exists
 | 
			
		||||
	err = process.Signal(syscall.Signal(0))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return pid, false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return pid, true, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RemovePID removes the PID file
 | 
			
		||||
func RemovePID(configPath string) error {
 | 
			
		||||
	pidFile := GetPIDFilePath(configPath)
 | 
			
		||||
	err := os.Remove(pidFile)
 | 
			
		||||
	if err != nil && !os.IsNotExist(err) {
 | 
			
		||||
		return fmt.Errorf("failed to remove PID file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ListRunningInstances returns a list of running KVS instances
 | 
			
		||||
func ListRunningInstances() ([]InstanceInfo, error) {
 | 
			
		||||
	var instances []InstanceInfo
 | 
			
		||||
 | 
			
		||||
	pidDir, err := getPIDDir()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if PID directory exists
 | 
			
		||||
	if _, err := os.Stat(pidDir); os.IsNotExist(err) {
 | 
			
		||||
		return instances, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	entries, err := os.ReadDir(pidDir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, fmt.Errorf("failed to read PID directory: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, entry := range entries {
 | 
			
		||||
		if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		name := strings.TrimSuffix(entry.Name(), ".pid")
 | 
			
		||||
		configPath := name + ".yaml" // Assume .yaml extension
 | 
			
		||||
 | 
			
		||||
		pid, running, err := ReadPID(configPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		instances = append(instances, InstanceInfo{
 | 
			
		||||
			Name:    name,
 | 
			
		||||
			PID:     pid,
 | 
			
		||||
			Running: running,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return instances, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// InstanceInfo holds information about a KVS instance
 | 
			
		||||
type InstanceInfo struct {
 | 
			
		||||
	Name    string
 | 
			
		||||
	PID     int
 | 
			
		||||
	Running bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// StopProcess stops a process by PID
 | 
			
		||||
func StopProcess(pid int) error {
 | 
			
		||||
	process, err := os.FindProcess(pid)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to find process: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Try graceful shutdown first (SIGTERM)
 | 
			
		||||
	if err := process.Signal(syscall.SIGTERM); err != nil {
 | 
			
		||||
		return fmt.Errorf("failed to send SIGTERM: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@@ -45,6 +45,7 @@ cleanup() {
 | 
			
		||||
    log_info "Cleaning up test environment..."
 | 
			
		||||
    pkill -f "$BINARY" 2>/dev/null || true
 | 
			
		||||
    rm -rf "$TEST_DIR" 2>/dev/null || true
 | 
			
		||||
    rm -rf "$HOME/.kvs" 2>/dev/null || true  # Clean up PID and log files from home dir
 | 
			
		||||
    sleep 2  # Allow processes to fully terminate
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -53,7 +54,7 @@ wait_for_service() {
 | 
			
		||||
    local port=$1
 | 
			
		||||
    local timeout=${2:-30}
 | 
			
		||||
    local count=0
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    while [ $count -lt $timeout ]; do
 | 
			
		||||
        if curl -s "http://localhost:$port/health" >/dev/null 2>&1; then
 | 
			
		||||
            return 0
 | 
			
		||||
@@ -64,6 +65,15 @@ wait_for_service() {
 | 
			
		||||
    return 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Get log file path for a config file (matches daemon naming convention)
 | 
			
		||||
get_log_file() {
 | 
			
		||||
    local config=$1
 | 
			
		||||
    local abs_path=$(realpath "$config")
 | 
			
		||||
    local basename=$(basename "$config")
 | 
			
		||||
    local dirname=$(basename $(dirname "$abs_path"))
 | 
			
		||||
    echo "$HOME/.kvs/logs/${dirname}_${basename}.log"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Test 1: Build verification
 | 
			
		||||
test_build() {
 | 
			
		||||
    test_start "Binary build verification"
 | 
			
		||||
@@ -82,7 +92,7 @@ test_build() {
 | 
			
		||||
# Test 2: Basic functionality
 | 
			
		||||
test_basic_functionality() {
 | 
			
		||||
    test_start "Basic functionality test"
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # Create basic config
 | 
			
		||||
    cat > basic.yaml <<EOF
 | 
			
		||||
node_id: "basic-test"
 | 
			
		||||
@@ -94,20 +104,20 @@ log_level: "error"
 | 
			
		||||
allow_anonymous_read: true
 | 
			
		||||
allow_anonymous_write: true
 | 
			
		||||
EOF
 | 
			
		||||
    
 | 
			
		||||
    # Start node
 | 
			
		||||
    $BINARY basic.yaml >/dev/null 2>&1 &
 | 
			
		||||
    local pid=$!
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    # Start node using daemon command
 | 
			
		||||
    $BINARY start basic.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 2
 | 
			
		||||
 | 
			
		||||
    if wait_for_service 8090; then
 | 
			
		||||
        # Test basic CRUD
 | 
			
		||||
        local put_result=$(curl -s -X PUT http://localhost:8090/kv/test/basic \
 | 
			
		||||
            -H "Content-Type: application/json" \
 | 
			
		||||
            -d '{"message":"hello world"}')
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        local get_result=$(curl -s http://localhost:8090/kv/test/basic)
 | 
			
		||||
        local message=$(echo "$get_result" | jq -r '.data.message' 2>/dev/null) # Adjusted jq path
 | 
			
		||||
        
 | 
			
		||||
        local message=$(echo "$get_result" | jq -r '.data.message' 2>/dev/null)
 | 
			
		||||
 | 
			
		||||
        if [ "$message" = "hello world" ]; then
 | 
			
		||||
            log_success "Basic CRUD operations work"
 | 
			
		||||
        else
 | 
			
		||||
@@ -116,9 +126,9 @@ EOF
 | 
			
		||||
    else
 | 
			
		||||
        log_error "Basic test node failed to start"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    kill $pid 2>/dev/null || true
 | 
			
		||||
    sleep 2
 | 
			
		||||
 | 
			
		||||
    $BINARY stop basic.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Test 3: Cluster formation
 | 
			
		||||
@@ -160,23 +170,22 @@ allow_anonymous_write: true
 | 
			
		||||
cluster_secret: "$CLUSTER_SECRET"
 | 
			
		||||
EOF
 | 
			
		||||
    
 | 
			
		||||
    # Start nodes
 | 
			
		||||
    $BINARY cluster1.yaml >/dev/null 2>&1 &
 | 
			
		||||
    local pid1=$!
 | 
			
		||||
    
 | 
			
		||||
    # Start nodes using daemon commands
 | 
			
		||||
    $BINARY start cluster1.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 2
 | 
			
		||||
 | 
			
		||||
    if ! wait_for_service 8101; then
 | 
			
		||||
        log_error "Cluster node 1 failed to start"
 | 
			
		||||
        kill $pid1 2>/dev/null || true
 | 
			
		||||
        $BINARY stop cluster1.yaml >/dev/null 2>&1
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    sleep 2 # Give node 1 a moment to fully initialize
 | 
			
		||||
    $BINARY cluster2.yaml >/dev/null 2>&1 &
 | 
			
		||||
    local pid2=$!
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    $BINARY start cluster2.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 2
 | 
			
		||||
 | 
			
		||||
    if ! wait_for_service 8102; then
 | 
			
		||||
        log_error "Cluster node 2 failed to start"
 | 
			
		||||
        kill $pid1 $pid2 2>/dev/null || true
 | 
			
		||||
        $BINARY stop cluster1.yaml cluster2.yaml >/dev/null 2>&1
 | 
			
		||||
        return 1
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
@@ -224,9 +233,9 @@ EOF
 | 
			
		||||
    else
 | 
			
		||||
        log_error "Cluster formation failed (N1 members: $node1_members, N2 members: $node2_members)"
 | 
			
		||||
    fi
 | 
			
		||||
    
 | 
			
		||||
    kill $pid1 $pid2 2>/dev/null || true
 | 
			
		||||
    sleep 2
 | 
			
		||||
 | 
			
		||||
    $BINARY stop cluster1.yaml cluster2.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Test 4: Conflict resolution (Merkle Tree based)
 | 
			
		||||
@@ -274,16 +283,15 @@ allow_anonymous_write: true
 | 
			
		||||
cluster_secret: "$CLUSTER_SECRET"
 | 
			
		||||
EOF
 | 
			
		||||
        
 | 
			
		||||
        # Start nodes
 | 
			
		||||
        # Start nodes using daemon commands
 | 
			
		||||
        # Node 1 started first, making it "older" for tie-breaker if timestamps are equal
 | 
			
		||||
        "$BINARY" conflict1.yaml >conflict1.log 2>&1 &
 | 
			
		||||
        local pid1=$!
 | 
			
		||||
        
 | 
			
		||||
        $BINARY start conflict1.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 2
 | 
			
		||||
 | 
			
		||||
        if wait_for_service 8111; then
 | 
			
		||||
            $BINARY start conflict2.yaml >/dev/null 2>&1
 | 
			
		||||
            sleep 2
 | 
			
		||||
            $BINARY conflict2.yaml >conflict2.log 2>&1 &
 | 
			
		||||
            local pid2=$!
 | 
			
		||||
            
 | 
			
		||||
 | 
			
		||||
            if wait_for_service 8112; then
 | 
			
		||||
                # Get initial data (full StoredValue)
 | 
			
		||||
                local node1_initial_full=$(curl -s http://localhost:8111/kv/test/conflict/data)
 | 
			
		||||
@@ -344,8 +352,10 @@ EOF
 | 
			
		||||
                        log_error "Resolved data has inconsistent UUID/Timestamp: N1_UUID=$node1_final_uuid, N1_TS=$node1_final_timestamp, N2_UUID=$node2_final_uuid, N2_TS=$node2_final_timestamp"
 | 
			
		||||
                    fi
 | 
			
		||||
 | 
			
		||||
                    # Optionally, check logs for conflict resolution messages
 | 
			
		||||
                    if grep -q "Conflict resolved" conflict1.log conflict2.log 2>/dev/null; then
 | 
			
		||||
                    # Check logs for conflict resolution messages
 | 
			
		||||
                    local log1=$(get_log_file conflict1.yaml)
 | 
			
		||||
                    local log2=$(get_log_file conflict2.yaml)
 | 
			
		||||
                    if grep -q "Conflict resolved" "$log1" "$log2" 2>/dev/null; then
 | 
			
		||||
                        log_success "Conflict resolution messages found in logs"
 | 
			
		||||
                    else
 | 
			
		||||
                        log_error "No 'Conflict resolved' messages found in logs, but data converged."
 | 
			
		||||
@@ -357,14 +367,14 @@ EOF
 | 
			
		||||
            else
 | 
			
		||||
                log_error "Conflict node 2 failed to start"
 | 
			
		||||
            fi
 | 
			
		||||
            
 | 
			
		||||
            kill $pid2 2>/dev/null || true
 | 
			
		||||
 | 
			
		||||
            $BINARY stop conflict2.yaml >/dev/null 2>&1
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Conflict node 1 failed to start"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        kill $pid1 2>/dev/null || true
 | 
			
		||||
        sleep 2
 | 
			
		||||
 | 
			
		||||
        $BINARY stop conflict1.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 1
 | 
			
		||||
    else
 | 
			
		||||
        cd "$TEST_DIR"
 | 
			
		||||
        log_error "Failed to create conflict test data. Ensure test_conflict.go is correct."
 | 
			
		||||
@@ -388,22 +398,21 @@ allow_anonymous_read: false
 | 
			
		||||
allow_anonymous_write: false
 | 
			
		||||
EOF
 | 
			
		||||
    
 | 
			
		||||
    # Start node
 | 
			
		||||
    $BINARY auth_test.yaml >auth_test.log 2>&1 &
 | 
			
		||||
    local pid=$!
 | 
			
		||||
    
 | 
			
		||||
    # Start node using daemon command
 | 
			
		||||
    $BINARY start auth_test.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 3  # Allow daemon to start and root account creation
 | 
			
		||||
 | 
			
		||||
    if wait_for_service 8095; then
 | 
			
		||||
        sleep 2  # Allow root account creation
 | 
			
		||||
        
 | 
			
		||||
        # Extract the token from logs
 | 
			
		||||
        local token=$(grep "Token:" auth_test.log | sed 's/.*Token: //' | tr -d '\n\r')
 | 
			
		||||
        
 | 
			
		||||
        local log_file=$(get_log_file auth_test.yaml)
 | 
			
		||||
        local token=$(grep "Token:" "$log_file" | sed 's/.*Token: //' | tr -d '\n\r')
 | 
			
		||||
 | 
			
		||||
        if [ -z "$token" ]; then
 | 
			
		||||
            log_error "Failed to extract authentication token from logs"
 | 
			
		||||
            kill $pid 2>/dev/null || true
 | 
			
		||||
            $BINARY stop auth_test.yaml >/dev/null 2>&1
 | 
			
		||||
            return
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Test 1: Admin endpoints should fail without authentication
 | 
			
		||||
        local no_auth_response=$(curl -s -X POST http://localhost:8095/api/users -H "Content-Type: application/json" -d '{"nickname":"test","password":"test"}')
 | 
			
		||||
        if echo "$no_auth_response" | grep -q "Unauthorized"; then
 | 
			
		||||
@@ -411,7 +420,7 @@ EOF
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Admin endpoints should reject unauthenticated requests, got: $no_auth_response"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Test 2: Admin endpoints should work with valid authentication
 | 
			
		||||
        local auth_response=$(curl -s -X POST http://localhost:8095/api/users -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"nickname":"authtest","password":"authtest"}')
 | 
			
		||||
        if echo "$auth_response" | grep -q "uuid"; then
 | 
			
		||||
@@ -419,7 +428,7 @@ EOF
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Admin endpoints should work with authentication, got: $auth_response"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Test 3: KV endpoints should require auth when anonymous access is disabled
 | 
			
		||||
        local kv_no_auth=$(curl -s -X PUT http://localhost:8095/kv/test/auth -H "Content-Type: application/json" -d '{"test":"auth"}')
 | 
			
		||||
        if echo "$kv_no_auth" | grep -q "Unauthorized"; then
 | 
			
		||||
@@ -427,7 +436,7 @@ EOF
 | 
			
		||||
        else
 | 
			
		||||
            log_error "KV endpoints should require auth when anonymous access disabled, got: $kv_no_auth"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        # Test 4: KV endpoints should work with valid authentication
 | 
			
		||||
        local kv_auth=$(curl -s -X PUT http://localhost:8095/kv/test/auth -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"test":"auth"}')
 | 
			
		||||
        if echo "$kv_auth" | grep -q "uuid\|timestamp" || [ -z "$kv_auth" ]; then
 | 
			
		||||
@@ -435,15 +444,167 @@ EOF
 | 
			
		||||
        else
 | 
			
		||||
            log_error "KV endpoints should work with authentication, got: $kv_auth"
 | 
			
		||||
        fi
 | 
			
		||||
        
 | 
			
		||||
        kill $pid 2>/dev/null || true
 | 
			
		||||
        sleep 2
 | 
			
		||||
 | 
			
		||||
        $BINARY stop auth_test.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 1
 | 
			
		||||
    else
 | 
			
		||||
        log_error "Auth test node failed to start"
 | 
			
		||||
        kill $pid 2>/dev/null || true
 | 
			
		||||
        $BINARY stop auth_test.yaml >/dev/null 2>&1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Test 6: Resource Metadata Management (Issue #12)
 | 
			
		||||
test_metadata_management() {
 | 
			
		||||
    test_start "Resource Metadata Management test (Issue #12)"
 | 
			
		||||
 | 
			
		||||
    # Create metadata test config
 | 
			
		||||
    cat > metadata_test.yaml <<EOF
 | 
			
		||||
node_id: "metadata-test"
 | 
			
		||||
bind_address: "127.0.0.1"
 | 
			
		||||
port: 8096
 | 
			
		||||
data_dir: "./metadata_test_data"
 | 
			
		||||
seed_nodes: []
 | 
			
		||||
log_level: "error"
 | 
			
		||||
auth_enabled: true
 | 
			
		||||
allow_anonymous_read: false
 | 
			
		||||
allow_anonymous_write: false
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
    # Start node using daemon command
 | 
			
		||||
    $BINARY start metadata_test.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 3  # Allow daemon to start and root account creation
 | 
			
		||||
 | 
			
		||||
    if wait_for_service 8096; then
 | 
			
		||||
        # Extract the token from logs
 | 
			
		||||
        local log_file=$(get_log_file metadata_test.yaml)
 | 
			
		||||
        local token=$(grep "Token:" "$log_file" | sed 's/.*Token: //' | tr -d '\n\r')
 | 
			
		||||
 | 
			
		||||
        if [ -z "$token" ]; then
 | 
			
		||||
            log_error "Failed to extract authentication token from logs"
 | 
			
		||||
            $BINARY stop metadata_test.yaml >/dev/null 2>&1
 | 
			
		||||
            return
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # First, create a KV resource
 | 
			
		||||
        curl -s -X PUT http://localhost:8096/kv/test/resource -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"data":"test"}' >/dev/null
 | 
			
		||||
        sleep 1
 | 
			
		||||
 | 
			
		||||
        # Test 1: Get metadata should fail for non-existent metadata (initially no metadata exists)
 | 
			
		||||
        local get_response=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token")
 | 
			
		||||
        local get_body=$(echo "$get_response" | head -n -1)
 | 
			
		||||
        local get_code=$(echo "$get_response" | tail -n 1)
 | 
			
		||||
 | 
			
		||||
        if [ "$get_code" = "404" ]; then
 | 
			
		||||
            log_success "GET metadata returns 404 for non-existent metadata"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "GET metadata should return 404 for non-existent metadata, got code: $get_code, body: $get_body"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 2: Update metadata should create new metadata
 | 
			
		||||
        local update_response=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"test-owner-123","permissions":3840}')
 | 
			
		||||
        if echo "$update_response" | grep -q "owner_uuid"; then
 | 
			
		||||
            log_success "PUT metadata creates metadata successfully"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "PUT metadata should create metadata, got: $update_response"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 3: Get metadata should now return the created metadata
 | 
			
		||||
        local get_response2=$(curl -s -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token")
 | 
			
		||||
        if echo "$get_response2" | grep -q "test-owner-123" && echo "$get_response2" | grep -q "3840"; then
 | 
			
		||||
            log_success "GET metadata returns created metadata"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "GET metadata should return created metadata, got: $get_response2"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 4: Update metadata should modify existing metadata
 | 
			
		||||
        local update_response2=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"new-owner-456"}')
 | 
			
		||||
        if echo "$update_response2" | grep -q "new-owner-456"; then
 | 
			
		||||
            log_success "PUT metadata updates existing metadata"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "PUT metadata should update metadata, got: $update_response2"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 5: Metadata endpoints should require authentication
 | 
			
		||||
        local no_auth=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata)
 | 
			
		||||
        local no_auth_code=$(echo "$no_auth" | tail -n 1)
 | 
			
		||||
        if [ "$no_auth_code" = "401" ]; then
 | 
			
		||||
            log_success "Metadata endpoints properly require authentication"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Metadata endpoints should require authentication, got code: $no_auth_code"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        $BINARY stop metadata_test.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 1
 | 
			
		||||
    else
 | 
			
		||||
        log_error "Metadata test node failed to start"
 | 
			
		||||
        $BINARY stop metadata_test.yaml >/dev/null 2>&1
 | 
			
		||||
    fi
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Test 7: Daemon commands (start, stop, status, restart)
 | 
			
		||||
test_daemon_commands() {
 | 
			
		||||
    test_start "Daemon command tests (start, stop, status, restart)"
 | 
			
		||||
 | 
			
		||||
    # Create daemon test config
 | 
			
		||||
    cat > daemon_test.yaml <<EOF
 | 
			
		||||
node_id: "daemon-test"
 | 
			
		||||
bind_address: "127.0.0.1"
 | 
			
		||||
port: 8097
 | 
			
		||||
data_dir: "./daemon_test_data"
 | 
			
		||||
seed_nodes: []
 | 
			
		||||
log_level: "error"
 | 
			
		||||
allow_anonymous_read: true
 | 
			
		||||
allow_anonymous_write: true
 | 
			
		||||
EOF
 | 
			
		||||
 | 
			
		||||
    # Test 1: Start command
 | 
			
		||||
    $BINARY start daemon_test.yaml >/dev/null 2>&1
 | 
			
		||||
    sleep 3  # Allow daemon to start
 | 
			
		||||
 | 
			
		||||
    if wait_for_service 8097 5; then
 | 
			
		||||
        log_success "Daemon 'start' command works"
 | 
			
		||||
 | 
			
		||||
        # Test 2: Status command shows running
 | 
			
		||||
        local status_output=$($BINARY status daemon_test.yaml 2>&1)
 | 
			
		||||
        if echo "$status_output" | grep -q "RUNNING"; then
 | 
			
		||||
            log_success "Daemon 'status' command shows RUNNING"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Daemon 'status' should show RUNNING, got: $status_output"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 3: Stop command
 | 
			
		||||
        $BINARY stop daemon_test.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 2
 | 
			
		||||
 | 
			
		||||
        # Check that service is actually stopped
 | 
			
		||||
        if ! curl -s "http://localhost:8097/health" >/dev/null 2>&1; then
 | 
			
		||||
            log_success "Daemon 'stop' command works"
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Daemon should be stopped but is still responding"
 | 
			
		||||
        fi
 | 
			
		||||
 | 
			
		||||
        # Test 4: Restart command
 | 
			
		||||
        $BINARY restart daemon_test.yaml >/dev/null 2>&1
 | 
			
		||||
        sleep 3
 | 
			
		||||
 | 
			
		||||
        if wait_for_service 8097 5; then
 | 
			
		||||
            log_success "Daemon 'restart' command works"
 | 
			
		||||
 | 
			
		||||
            # Clean up
 | 
			
		||||
            $BINARY stop daemon_test.yaml >/dev/null 2>&1
 | 
			
		||||
            sleep 1
 | 
			
		||||
        else
 | 
			
		||||
            log_error "Daemon 'restart' failed to start service"
 | 
			
		||||
        fi
 | 
			
		||||
    else
 | 
			
		||||
        log_error "Daemon 'start' command failed"
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    # Ensure cleanup
 | 
			
		||||
    pkill -f "daemon_test.yaml" 2>/dev/null || true
 | 
			
		||||
    sleep 1
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Main test execution
 | 
			
		||||
main() {
 | 
			
		||||
    echo "=================================================="
 | 
			
		||||
@@ -462,7 +623,9 @@ main() {
 | 
			
		||||
    test_cluster_formation
 | 
			
		||||
    test_conflict_resolution
 | 
			
		||||
    test_authentication_middleware
 | 
			
		||||
    
 | 
			
		||||
    test_metadata_management
 | 
			
		||||
    test_daemon_commands
 | 
			
		||||
 | 
			
		||||
    # Results
 | 
			
		||||
    echo "=================================================="
 | 
			
		||||
    echo "              Test Results"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										207
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										207
									
								
								main.go
									
									
									
									
									
								
							@@ -6,25 +6,90 @@ import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"syscall"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"kvs/config"
 | 
			
		||||
	"kvs/daemon"
 | 
			
		||||
	"kvs/server"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	configPath := "./config.yaml"
 | 
			
		||||
 | 
			
		||||
	// Simple CLI argument parsing
 | 
			
		||||
	if len(os.Args) > 1 {
 | 
			
		||||
		configPath = os.Args[1]
 | 
			
		||||
	if len(os.Args) < 2 {
 | 
			
		||||
		// No arguments - run in foreground with default config
 | 
			
		||||
		runServer("./config.yaml", false)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if this is a daemon spawn
 | 
			
		||||
	if os.Args[1] == "--daemon" {
 | 
			
		||||
		if len(os.Args) < 3 {
 | 
			
		||||
			fmt.Fprintf(os.Stderr, "Error: --daemon flag requires config path\n")
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		runServer(os.Args[2], true)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse subcommand
 | 
			
		||||
	command := os.Args[1]
 | 
			
		||||
 | 
			
		||||
	switch command {
 | 
			
		||||
	case "start":
 | 
			
		||||
		if len(os.Args) < 3 {
 | 
			
		||||
			fmt.Fprintf(os.Stderr, "Usage: kvs start <config>\n")
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		cmdStart(normalizeConfigPath(os.Args[2]))
 | 
			
		||||
 | 
			
		||||
	case "stop":
 | 
			
		||||
		if len(os.Args) < 3 {
 | 
			
		||||
			fmt.Fprintf(os.Stderr, "Usage: kvs stop <config>\n")
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		cmdStop(normalizeConfigPath(os.Args[2]))
 | 
			
		||||
 | 
			
		||||
	case "restart":
 | 
			
		||||
		if len(os.Args) < 3 {
 | 
			
		||||
			fmt.Fprintf(os.Stderr, "Usage: kvs restart <config>\n")
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		cmdRestart(normalizeConfigPath(os.Args[2]))
 | 
			
		||||
 | 
			
		||||
	case "status":
 | 
			
		||||
		if len(os.Args) > 2 {
 | 
			
		||||
			cmdStatusSingle(normalizeConfigPath(os.Args[2]))
 | 
			
		||||
		} else {
 | 
			
		||||
			cmdStatusAll()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	case "help", "--help", "-h":
 | 
			
		||||
		printHelp()
 | 
			
		||||
 | 
			
		||||
	default:
 | 
			
		||||
		// Backward compatibility: assume it's a config file path
 | 
			
		||||
		runServer(command, false)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func runServer(configPath string, isDaemon bool) {
 | 
			
		||||
	cfg, err := config.Load(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write PID file if running as daemon
 | 
			
		||||
	if isDaemon {
 | 
			
		||||
		if err := daemon.WritePID(configPath); err != nil {
 | 
			
		||||
			fmt.Fprintf(os.Stderr, "Failed to write PID file: %v\n", err)
 | 
			
		||||
			os.Exit(1)
 | 
			
		||||
		}
 | 
			
		||||
		defer daemon.RemovePID(configPath)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	kvServer, err := server.NewServer(cfg)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to create server: %v\n", err)
 | 
			
		||||
@@ -45,3 +110,135 @@ func main() {
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cmdStart(configPath string) {
 | 
			
		||||
	if err := daemon.Daemonize(configPath); err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to start: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cmdStop(configPath string) {
 | 
			
		||||
	pid, running, err := daemon.ReadPID(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !running {
 | 
			
		||||
		fmt.Printf("Instance '%s' is not running\n", configPath)
 | 
			
		||||
		// Clean up stale PID file
 | 
			
		||||
		daemon.RemovePID(configPath)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Printf("Stopping instance '%s' (PID %d)...\n", configPath, pid)
 | 
			
		||||
	if err := daemon.StopProcess(pid); err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to stop process: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Wait a bit and verify it stopped
 | 
			
		||||
	time.Sleep(1 * time.Second)
 | 
			
		||||
	_, stillRunning, _ := daemon.ReadPID(configPath)
 | 
			
		||||
	if stillRunning {
 | 
			
		||||
		fmt.Printf("Warning: Process may still be running\n")
 | 
			
		||||
	} else {
 | 
			
		||||
		daemon.RemovePID(configPath)
 | 
			
		||||
		fmt.Printf("Stopped successfully\n")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cmdRestart(configPath string) {
 | 
			
		||||
	// Check if running
 | 
			
		||||
	_, running, err := daemon.ReadPID(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to check status: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if running {
 | 
			
		||||
		cmdStop(configPath)
 | 
			
		||||
		// Wait a bit for clean shutdown
 | 
			
		||||
		time.Sleep(2 * time.Second)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmdStart(configPath)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cmdStatusSingle(configPath string) {
 | 
			
		||||
	pid, running, err := daemon.ReadPID(configPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if running {
 | 
			
		||||
		fmt.Printf("Instance '%s': RUNNING (PID %d)\n", configPath, pid)
 | 
			
		||||
	} else if pid > 0 {
 | 
			
		||||
		fmt.Printf("Instance '%s': STOPPED (stale PID %d)\n", configPath, pid)
 | 
			
		||||
	} else {
 | 
			
		||||
		fmt.Printf("Instance '%s': STOPPED\n", configPath)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func cmdStatusAll() {
 | 
			
		||||
	instances, err := daemon.ListRunningInstances()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		fmt.Fprintf(os.Stderr, "Failed to list instances: %v\n", err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(instances) == 0 {
 | 
			
		||||
		fmt.Println("No KVS instances found")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println("KVS Instances:")
 | 
			
		||||
	for _, inst := range instances {
 | 
			
		||||
		status := "STOPPED"
 | 
			
		||||
		if inst.Running {
 | 
			
		||||
			status = "RUNNING"
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Printf("  %-20s %s (PID %d)\n", inst.Name, status, inst.PID)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// normalizeConfigPath ensures config path has .yaml extension if not specified
 | 
			
		||||
func normalizeConfigPath(path string) string {
 | 
			
		||||
	// If path doesn't have an extension, add .yaml
 | 
			
		||||
	if filepath.Ext(path) == "" {
 | 
			
		||||
		return path + ".yaml"
 | 
			
		||||
	}
 | 
			
		||||
	return path
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getConfigIdentifier returns the identifier for a config (basename without extension)
 | 
			
		||||
// This is used for PID files and status display
 | 
			
		||||
func getConfigIdentifier(path string) string {
 | 
			
		||||
	basename := filepath.Base(path)
 | 
			
		||||
	return strings.TrimSuffix(basename, filepath.Ext(basename))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func printHelp() {
 | 
			
		||||
	help := `KVS - Distributed Key-Value Store
 | 
			
		||||
 | 
			
		||||
Usage:
 | 
			
		||||
  kvs [config.yaml]              Run in foreground (default: ./config.yaml)
 | 
			
		||||
  kvs start <config>             Start as daemon (.yaml extension optional)
 | 
			
		||||
  kvs stop <config>              Stop daemon (.yaml extension optional)
 | 
			
		||||
  kvs restart <config>           Restart daemon (.yaml extension optional)
 | 
			
		||||
  kvs status [config]            Show status (all instances if no config given)
 | 
			
		||||
  kvs help                       Show this help
 | 
			
		||||
 | 
			
		||||
Examples:
 | 
			
		||||
  kvs                            # Run with ./config.yaml in foreground
 | 
			
		||||
  kvs node1.yaml                 # Run with node1.yaml in foreground
 | 
			
		||||
  kvs start node1                # Start node1.yaml as daemon
 | 
			
		||||
  kvs start node1.yaml           # Same as above
 | 
			
		||||
  kvs stop node1                 # Stop node1 daemon
 | 
			
		||||
  kvs status                     # Show all running instances
 | 
			
		||||
  kvs status node1               # Show status of node1
 | 
			
		||||
`
 | 
			
		||||
	fmt.Print(help)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -213,6 +213,104 @@ func (s *Server) deleteKVHandler(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	s.logger.WithField("path", path).Info("Value deleted")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getResourceMetadataHandler retrieves metadata for a KV resource
 | 
			
		||||
func (s *Server) getResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	vars := mux.Vars(r)
 | 
			
		||||
	path := vars["path"]
 | 
			
		||||
 | 
			
		||||
	// Get metadata from storage
 | 
			
		||||
	metadata, err := s.authService.GetResourceMetadata(path)
 | 
			
		||||
	if err == badger.ErrKeyNotFound {
 | 
			
		||||
		http.Error(w, "Not Found: No metadata exists for this resource", http.StatusNotFound)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata")
 | 
			
		||||
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response := types.GetResourceMetadataResponse{
 | 
			
		||||
		OwnerUUID:   metadata.OwnerUUID,
 | 
			
		||||
		GroupUUID:   metadata.GroupUUID,
 | 
			
		||||
		Permissions: metadata.Permissions,
 | 
			
		||||
		TTL:         metadata.TTL,
 | 
			
		||||
		CreatedAt:   metadata.CreatedAt,
 | 
			
		||||
		UpdatedAt:   metadata.UpdatedAt,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	json.NewEncoder(w).Encode(response)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// updateResourceMetadataHandler updates metadata for a KV resource
 | 
			
		||||
func (s *Server) updateResourceMetadataHandler(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	vars := mux.Vars(r)
 | 
			
		||||
	path := vars["path"]
 | 
			
		||||
 | 
			
		||||
	// Parse request body
 | 
			
		||||
	var req types.UpdateResourceMetadataRequest
 | 
			
		||||
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 | 
			
		||||
		http.Error(w, "Bad Request: Invalid JSON", http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Get existing metadata or create new one
 | 
			
		||||
	metadata, err := s.authService.GetResourceMetadata(path)
 | 
			
		||||
	if err == badger.ErrKeyNotFound {
 | 
			
		||||
		// Create new metadata with defaults
 | 
			
		||||
		metadata = &types.ResourceMetadata{
 | 
			
		||||
			OwnerUUID:   "",
 | 
			
		||||
			GroupUUID:   "",
 | 
			
		||||
			Permissions: types.DefaultPermissions,
 | 
			
		||||
			TTL:         "",
 | 
			
		||||
			CreatedAt:   time.Now().Unix(),
 | 
			
		||||
			UpdatedAt:   time.Now().Unix(),
 | 
			
		||||
		}
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata")
 | 
			
		||||
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Update only provided fields
 | 
			
		||||
	if req.OwnerUUID != nil {
 | 
			
		||||
		metadata.OwnerUUID = *req.OwnerUUID
 | 
			
		||||
	}
 | 
			
		||||
	if req.GroupUUID != nil {
 | 
			
		||||
		metadata.GroupUUID = *req.GroupUUID
 | 
			
		||||
	}
 | 
			
		||||
	if req.Permissions != nil {
 | 
			
		||||
		metadata.Permissions = *req.Permissions
 | 
			
		||||
	}
 | 
			
		||||
	metadata.UpdatedAt = time.Now().Unix()
 | 
			
		||||
 | 
			
		||||
	// Store updated metadata
 | 
			
		||||
	if err := s.authService.SetResourceMetadata(path, metadata); err != nil {
 | 
			
		||||
		s.logger.WithError(err).WithField("path", path).Error("Failed to update resource metadata")
 | 
			
		||||
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response := types.GetResourceMetadataResponse{
 | 
			
		||||
		OwnerUUID:   metadata.OwnerUUID,
 | 
			
		||||
		GroupUUID:   metadata.GroupUUID,
 | 
			
		||||
		Permissions: metadata.Permissions,
 | 
			
		||||
		TTL:         metadata.TTL,
 | 
			
		||||
		CreatedAt:   metadata.CreatedAt,
 | 
			
		||||
		UpdatedAt:   metadata.UpdatedAt,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	json.NewEncoder(w).Encode(response)
 | 
			
		||||
 | 
			
		||||
	s.logger.WithFields(logrus.Fields{
 | 
			
		||||
		"path":       path,
 | 
			
		||||
		"owner_uuid": metadata.OwnerUUID,
 | 
			
		||||
		"group_uuid": metadata.GroupUUID,
 | 
			
		||||
	}).Info("Resource metadata updated")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// isClusterMember checks if request is from a cluster member
 | 
			
		||||
func (s *Server) isClusterMember(remoteAddr string) bool {
 | 
			
		||||
	host, _, err := net.SplitHostPort(remoteAddr)
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,18 @@ func (s *Server) setupRoutes() *mux.Router {
 | 
			
		||||
	// Health endpoint (always available)
 | 
			
		||||
	router.HandleFunc("/health", s.healthHandler).Methods("GET")
 | 
			
		||||
 | 
			
		||||
	// Resource Metadata Management endpoints (Issue #12) - Must come BEFORE general KV routes
 | 
			
		||||
	// These need to be registered first to prevent /kv/{path:.+} from matching metadata paths
 | 
			
		||||
	if s.config.AuthEnabled {
 | 
			
		||||
		router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware(
 | 
			
		||||
			[]string{"admin:users:read"}, nil, "",
 | 
			
		||||
		)(s.getResourceMetadataHandler)).Methods("GET")
 | 
			
		||||
 | 
			
		||||
		router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware(
 | 
			
		||||
			[]string{"admin:users:update"}, nil, "",
 | 
			
		||||
		)(s.updateResourceMetadataHandler)).Methods("PUT")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// KV endpoints (with conditional authentication based on anonymous access settings)
 | 
			
		||||
	// GET endpoint - require auth if anonymous read is disabled
 | 
			
		||||
	if s.config.AuthEnabled && !s.config.AllowAnonymousRead {
 | 
			
		||||
 
 | 
			
		||||
@@ -131,6 +131,22 @@ type CreateTokenResponse struct {
 | 
			
		||||
	ExpiresAt int64  `json:"expires_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Resource Metadata Management API structures (Issue #12)
 | 
			
		||||
type GetResourceMetadataResponse struct {
 | 
			
		||||
	OwnerUUID   string `json:"owner_uuid"`
 | 
			
		||||
	GroupUUID   string `json:"group_uuid"`
 | 
			
		||||
	Permissions int    `json:"permissions"`
 | 
			
		||||
	TTL         string `json:"ttl"`
 | 
			
		||||
	CreatedAt   int64  `json:"created_at"`
 | 
			
		||||
	UpdatedAt   int64  `json:"updated_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UpdateResourceMetadataRequest struct {
 | 
			
		||||
	OwnerUUID   *string `json:"owner_uuid,omitempty"`
 | 
			
		||||
	GroupUUID   *string `json:"group_uuid,omitempty"`
 | 
			
		||||
	Permissions *int    `json:"permissions,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Cluster and member management types
 | 
			
		||||
type Member struct {
 | 
			
		||||
	ID              string `json:"id"`
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user