mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Consolidate backend: drop vendored APwhy, converge on goose + sqlc
Phase 0 cleanup verified end-to-end:
- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
unwired copy (internal/api/server.go + static bundle + cloned
internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
routes under /api/v1/gateway/* so they stop colliding with the
native /services paths. Fix handlers against the real schema:
UUID ids via RETURNING, integer enabled flags, plan validation,
service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
schema (52 tables) minus APwhy's parallel auth/RBAC objects;
idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
replays cleanly over legacy installs. Add a drop migration for the
dead objects and delete the legacy filename-tracked migrations/
loader, directory, and Dockerfile copy. Verified via cmd/migrate:
fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
cmds, and legacy 0001_init.sql backups.
Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
37a56dc6b6
commit
9e3aa3aa17
153 files changed
+6989
-11128
No files matched your search
@@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=go-builder /app/main ./main
|
COPY --from=go-builder /app/main ./main
|
||||||
COPY --from=go-builder /app/migrations ./migrations
|
|
||||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||||
COPY --from=auth-builder /auth ./auth
|
COPY --from=auth-builder /auth ./auth
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ internal/
|
|||||||
├── config/ - Configuration management
|
├── config/ - Configuration management
|
||||||
├── database/ - Database connections and migrations
|
├── database/ - Database connections and migrations
|
||||||
└── middleware/ - HTTP middleware
|
└── middleware/ - HTTP middleware
|
||||||
migrations/ - SQL migration files
|
migrations_goose/ - Goose-managed migrations (single migration path)
|
||||||
migrations_goose/ - Goose-managed migrations for new schema changes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Endpoints
|
### Adding New Endpoints
|
||||||
@@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes
|
|||||||
1. Create handler functions in `internal/api/`
|
1. Create handler functions in `internal/api/`
|
||||||
2. Add routes in `internal/api/routes.go`
|
2. Add routes in `internal/api/routes.go`
|
||||||
3. Update database schema:
|
3. Update database schema:
|
||||||
- Legacy chain lives in `migrations/` and is executed first.
|
- All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`).
|
||||||
- New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`).
|
- `20260401163000_baseline.sql` carries the consolidated schema; append new files after it.
|
||||||
- Keep legacy files immutable after they are shipped.
|
- Never edit the baseline in place; always add a new migration file.
|
||||||
|
|
||||||
### SQLC (Type-Safe Queries)
|
### SQLC (Type-Safe Queries)
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"containr/internal/apwhy/api"
|
|
||||||
"containr/internal/apwhy/config"
|
|
||||||
"containr/internal/apwhy/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg := config.Load()
|
|
||||||
|
|
||||||
store, err := storage.Open(cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to initialize storage: %v", err)
|
|
||||||
}
|
|
||||||
defer store.Close()
|
|
||||||
|
|
||||||
server := api.NewServer(store, cfg)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
||||||
log.Printf("APwhy server listening on http://localhost%s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, server.Handler()); err != nil {
|
|
||||||
log.Fatalf("server exited: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,11 +25,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR")
|
|
||||||
if legacyDir == "" {
|
|
||||||
legacyDir = "migrations"
|
|
||||||
}
|
|
||||||
|
|
||||||
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
|
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
|
||||||
if gooseDir == "" {
|
if gooseDir == "" {
|
||||||
gooseDir = "migrations_goose"
|
gooseDir = "migrations_goose"
|
||||||
@@ -41,24 +36,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "up":
|
case "up", "goose-up":
|
||||||
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
||||||
if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil {
|
if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil {
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Fatalf("Migration failed: %v", err)
|
log.Fatalf("Migration failed: %v", err)
|
||||||
}
|
}
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Println("Legacy + goose migrations completed successfully")
|
log.Println("Migrations completed successfully")
|
||||||
case "legacy-up":
|
|
||||||
if err := db.Migrate(legacyDir); err != nil {
|
|
||||||
log.Fatalf("Legacy migration failed: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("Legacy migrations completed successfully")
|
|
||||||
case "goose-up":
|
|
||||||
if err := db.MigrateGoose(gooseDir); err != nil {
|
|
||||||
log.Fatalf("Goose migration failed: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("Goose migrations completed successfully")
|
|
||||||
case "goose-status":
|
case "goose-status":
|
||||||
if err := db.GooseStatus(gooseDir); err != nil {
|
if err := db.GooseStatus(gooseDir); err != nil {
|
||||||
log.Fatalf("Goose status failed: %v", err)
|
log.Fatalf("Goose status failed: %v", err)
|
||||||
@@ -73,6 +58,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("Created goose migration %q in %q", name, gooseDir)
|
log.Printf("Created goose migration %q in %q", name, gooseDir)
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command)
|
log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func main() {
|
|||||||
// Run startup migrations unless explicitly disabled.
|
// Run startup migrations unless explicitly disabled.
|
||||||
if cfg.AutoMigrate {
|
if cfg.AutoMigrate {
|
||||||
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
||||||
if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil {
|
if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil {
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Fatalf("Failed to run database migrations: %v", err)
|
log.Fatalf("Failed to run database migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"containr/internal/build"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fmt.Println("🧪 Testing Build Manager Detection...")
|
|
||||||
|
|
||||||
// Test build type detection on the current project
|
|
||||||
fmt.Println("\n📁 Testing on current project (has package.json)...")
|
|
||||||
|
|
||||||
// Note: We can't fully test BuildManager without a docker client,
|
|
||||||
// but we can test the detection logic
|
|
||||||
|
|
||||||
// Create mock scenarios
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"Node.js project (package.json)", "railpack"},
|
|
||||||
{"Python project (requirements.txt)", "railpack"},
|
|
||||||
{"Go project (go.mod)", "railpack"},
|
|
||||||
{"Dockerfile project", "dockerfile"},
|
|
||||||
{"Unknown project", "nixpacks"},
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n🎯 Expected detection priorities:")
|
|
||||||
for _, tc := range testCases {
|
|
||||||
fmt.Printf(" • %s → %s\n", tc.name, tc.expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Railpack builder directly
|
|
||||||
builder := build.NewRailpackBuilder("/tmp/test", nil)
|
|
||||||
|
|
||||||
fmt.Println("\n🔍 Testing Railpack detection on current project:")
|
|
||||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("❌ Detection failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("✅ Railpack can build this project!")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n📋 Build Priority Order:")
|
|
||||||
fmt.Println(" 1. Dockerfile (if present)")
|
|
||||||
fmt.Println(" 2. Railpack (primary choice)")
|
|
||||||
fmt.Println(" 3. Nixpacks (fallback)")
|
|
||||||
fmt.Println(" 4. Prebuilt (manual)")
|
|
||||||
|
|
||||||
fmt.Println("\n🚀 Build Manager Integration Test Complete!")
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"containr/internal/build"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Create a RailpackBuilder
|
|
||||||
builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil)
|
|
||||||
|
|
||||||
// Test detection on the current project (has package.json)
|
|
||||||
fmt.Println("Testing Railpack detection on current project...")
|
|
||||||
|
|
||||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("❌ Railpack detection failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("✅ Railpack can build this project!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show supported frameworks
|
|
||||||
fmt.Println("\nSupported frameworks:")
|
|
||||||
frameworks := builder.GetSupportedFrameworks()
|
|
||||||
for _, fw := range frameworks {
|
|
||||||
fmt.Printf(" - %s\n", fw)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n✅ Railpack integration test completed successfully!")
|
|
||||||
}
|
|
||||||
+1
-18
@@ -8,11 +8,9 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/go-playground/validator/v10 v10.30.1
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.7.1
|
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/pressly/goose/v3 v3.24.1
|
github.com/pressly/goose/v3 v3.24.1
|
||||||
@@ -22,13 +20,9 @@ require (
|
|||||||
github.com/sqlc-dev/pqtype v0.3.0
|
github.com/sqlc-dev/pqtype v0.3.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
|
||||||
gorm.io/gorm v1.31.1
|
|
||||||
modernc.org/sqlite v1.39.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -40,7 +34,6 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
@@ -52,11 +45,6 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -68,13 +56,11 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/morikuni/aec v1.1.0 // indirect
|
github.com/morikuni/aec v1.1.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
@@ -95,7 +81,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
@@ -106,7 +91,5 @@ require (
|
|||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
)
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
@@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
|||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
@@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -259,36 +241,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
|
||||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
|
||||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package analytics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
APIKey string
|
|
||||||
WebsiteID string
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
APIKey: apiKey,
|
|
||||||
WebsiteID: websiteID,
|
|
||||||
HTTP: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Enabled() bool {
|
|
||||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return map[string]any{
|
|
||||||
"enabled": false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
|
||||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
|
||||||
u.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload["enabled"] = true
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
+400
-251
@@ -1,7 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,26 +12,29 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"github.com/sqlc-dev/pqtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NodeAgent represents a container orchestration agent
|
// NodeAgent represents a container orchestration agent
|
||||||
type NodeAgent struct {
|
type NodeAgent struct {
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name" gorm:"not null"`
|
Name string `json:"name"`
|
||||||
Hostname string `json:"hostname" gorm:"not null"`
|
Hostname string `json:"hostname"`
|
||||||
IPAddress string `json:"ip_address" gorm:"not null"`
|
IPAddress string `json:"ip_address"`
|
||||||
Port int `json:"port" gorm:"not null"`
|
Port int `json:"port"`
|
||||||
Status string `json:"status" gorm:"default:'offline'"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
|
Capabilities AgentCapabilities `json:"capabilities"`
|
||||||
Resources NodeResources `json:"resources" gorm:"serializer:json"`
|
Resources NodeResources `json:"resources"`
|
||||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"`
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentCapabilities defines what the agent can do
|
// AgentCapabilities defines what the agent can do
|
||||||
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
|
|||||||
|
|
||||||
// ContainerInstance represents a container running on an agent
|
// ContainerInstance represents a container running on an agent
|
||||||
type ContainerInstance struct {
|
type ContainerInstance struct {
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name" gorm:"not null"`
|
Name string `json:"name"`
|
||||||
Image string `json:"image" gorm:"not null"`
|
Image string `json:"image"`
|
||||||
ProjectID string `json:"project_id" gorm:"not null"`
|
ProjectID string `json:"project_id"`
|
||||||
ServiceID string `json:"service_id" gorm:"not null"`
|
ServiceID string `json:"service_id"`
|
||||||
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
|
NodeAgentID string `json:"node_agent_id"`
|
||||||
Status ContainerStatus `json:"status" gorm:"serializer:json"`
|
Status ContainerStatus `json:"status"`
|
||||||
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
|
Resources ContainerResources `json:"resources"`
|
||||||
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
|
Ports []PortMapping `json:"ports"`
|
||||||
Environment map[string]string `json:"environment" gorm:"serializer:json"`
|
Environment map[string]string `json:"environment"`
|
||||||
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
|
Volumes []VolumeMount `json:"volumes"`
|
||||||
Networks []string `json:"networks" gorm:"serializer:json"`
|
Networks []string `json:"networks"`
|
||||||
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
|
RestartPolicy RestartPolicy `json:"restart_policy"`
|
||||||
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
|
HealthCheck *HealthCheck `json:"health_check"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
StartedAt *time.Time `json:"started_at"`
|
StartedAt *time.Time `json:"started_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -155,12 +161,12 @@ type HealthCheck struct {
|
|||||||
|
|
||||||
// AgentCommand represents a command sent to an agent
|
// AgentCommand represents a command sent to an agent
|
||||||
type AgentCommand struct {
|
type AgentCommand struct {
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type" gorm:"not null"`
|
Type string `json:"type"`
|
||||||
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
|
NodeAgentID string `json:"node_agent_id"`
|
||||||
ContainerID *string `json:"container_id"`
|
ContainerID *string `json:"container_id"`
|
||||||
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
|
Payload map[string]interface{} `json:"payload"`
|
||||||
Status string `json:"status" gorm:"default:'pending'"`
|
Status string `json:"status"`
|
||||||
Result *string `json:"result"`
|
Result *string `json:"result"`
|
||||||
Error *string `json:"error"`
|
Error *string `json:"error"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentHeartbeatRecord struct {
|
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
|
||||||
NodeAgentID string `json:"node_agent_id" gorm:"index;not null"`
|
|
||||||
Timestamp time.Time `json:"timestamp" gorm:"index;not null"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Resources NodeResources `json:"resources" gorm:"serializer:json"`
|
|
||||||
ContainerCount int `json:"container_count"`
|
|
||||||
SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"`
|
|
||||||
Uptime int64 `json:"uptime"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (AgentHeartbeatRecord) TableName() string {
|
|
||||||
return "agent_heartbeats"
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemLoad struct {
|
type SystemLoad struct {
|
||||||
Load1M float64 `json:"load_1m"`
|
Load1M float64 `json:"load_1m"`
|
||||||
Load5M float64 `json:"load_5m"`
|
Load5M float64 `json:"load_5m"`
|
||||||
@@ -206,13 +195,125 @@ type SystemLoad struct {
|
|||||||
|
|
||||||
// NodeAgentHandler handles agent-related endpoints
|
// NodeAgentHandler handles agent-related endpoints
|
||||||
type NodeAgentHandler struct {
|
type NodeAgentHandler struct {
|
||||||
db *gorm.DB
|
q *sqlcdb.Queries
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
|
func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
|
||||||
return &NodeAgentHandler{db: db}
|
return &NodeAgentHandler{q: sqlcdb.New(db.DB)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- row <-> API type conversion helpers ---
|
||||||
|
|
||||||
|
func rawJSON(v interface{}) pqtype.NullRawMessage {
|
||||||
|
if v == nil {
|
||||||
|
return pqtype.NullRawMessage{}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return pqtype.NullRawMessage{}
|
||||||
|
}
|
||||||
|
return pqtype.NullRawMessage{RawMessage: b, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
|
||||||
|
if raw.Valid && len(raw.RawMessage) > 0 {
|
||||||
|
_ = json.Unmarshal(raw.RawMessage, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
|
||||||
|
agent := NodeAgent{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Hostname: row.Hostname,
|
||||||
|
IPAddress: row.IpAddress,
|
||||||
|
Port: int(row.Port),
|
||||||
|
Status: row.Status.String,
|
||||||
|
Version: row.Version.String,
|
||||||
|
Metadata: map[string]interface{}{},
|
||||||
|
}
|
||||||
|
if row.LastHeartbeat.Valid {
|
||||||
|
agent.LastHeartbeat = row.LastHeartbeat.Time
|
||||||
|
}
|
||||||
|
if row.CreatedAt.Valid {
|
||||||
|
agent.CreatedAt = row.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if row.UpdatedAt.Valid {
|
||||||
|
agent.UpdatedAt = row.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
unmarshalRaw(row.Capabilities, &agent.Capabilities)
|
||||||
|
unmarshalRaw(row.Resources, &agent.Resources)
|
||||||
|
unmarshalRaw(row.Metadata, &agent.Metadata)
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance {
|
||||||
|
container := ContainerInstance{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
Image: row.Image,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
ServiceID: row.ServiceID,
|
||||||
|
NodeAgentID: row.NodeAgentID,
|
||||||
|
}
|
||||||
|
if row.CreatedAt.Valid {
|
||||||
|
container.CreatedAt = row.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if row.UpdatedAt.Valid {
|
||||||
|
container.UpdatedAt = row.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
if row.StartedAt.Valid {
|
||||||
|
t := row.StartedAt.Time
|
||||||
|
container.StartedAt = &t
|
||||||
|
}
|
||||||
|
unmarshalRaw(row.Status, &container.Status)
|
||||||
|
unmarshalRaw(row.Resources, &container.Resources)
|
||||||
|
unmarshalRaw(row.Ports, &container.Ports)
|
||||||
|
unmarshalRaw(row.Environment, &container.Environment)
|
||||||
|
unmarshalRaw(row.Volumes, &container.Volumes)
|
||||||
|
unmarshalRaw(row.Networks, &container.Networks)
|
||||||
|
unmarshalRaw(row.RestartPolicy, &container.RestartPolicy)
|
||||||
|
if row.HealthCheck.Valid {
|
||||||
|
var hc HealthCheck
|
||||||
|
if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil {
|
||||||
|
container.HealthCheck = &hc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandFromRow(row sqlcdb.AgentCommand) AgentCommand {
|
||||||
|
cmd := AgentCommand{
|
||||||
|
ID: row.ID,
|
||||||
|
Type: row.Type,
|
||||||
|
NodeAgentID: row.NodeAgentID,
|
||||||
|
Status: row.Status.String,
|
||||||
|
}
|
||||||
|
if row.ContainerID.Valid {
|
||||||
|
cmd.ContainerID = &row.ContainerID.String
|
||||||
|
}
|
||||||
|
if row.Result.Valid {
|
||||||
|
cmd.Result = &row.Result.String
|
||||||
|
}
|
||||||
|
if row.Error.Valid {
|
||||||
|
cmd.Error = &row.Error.String
|
||||||
|
}
|
||||||
|
if row.CreatedAt.Valid {
|
||||||
|
cmd.CreatedAt = row.CreatedAt.Time
|
||||||
|
}
|
||||||
|
if row.UpdatedAt.Valid {
|
||||||
|
cmd.UpdatedAt = row.UpdatedAt.Time
|
||||||
|
}
|
||||||
|
if row.CompletedAt.Valid {
|
||||||
|
t := row.CompletedAt.Time
|
||||||
|
cmd.CompletedAt = &t
|
||||||
|
}
|
||||||
|
unmarshalRaw(row.Payload, &cmd.Payload)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
|
||||||
// RegisterAgent handles agent registration
|
// RegisterAgent handles agent registration
|
||||||
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if agent already exists
|
ctx := c.Request.Context()
|
||||||
var existingAgent NodeAgent
|
|
||||||
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
|
|
||||||
// Update existing agent
|
|
||||||
existingAgent.Name = req.Name
|
|
||||||
existingAgent.Port = req.Port
|
|
||||||
existingAgent.Capabilities = req.Capabilities
|
|
||||||
existingAgent.Status = "connecting"
|
|
||||||
existingAgent.LastHeartbeat = time.Now()
|
|
||||||
|
|
||||||
if err := h.db.Save(&existingAgent).Error; err != nil {
|
// Re-registering an existing host updates it in place.
|
||||||
|
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
IpAddress: req.IPAddress,
|
||||||
|
}); err == nil {
|
||||||
|
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
Name: req.Name,
|
||||||
|
Hostname: existing.Hostname,
|
||||||
|
IpAddress: existing.IpAddress,
|
||||||
|
Port: int32(req.Port),
|
||||||
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
|
Version: existing.Version,
|
||||||
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
|
Resources: existing.Resources,
|
||||||
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
Metadata: existing.Metadata,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"agent_id": existingAgent.ID,
|
"agent_id": updated.ID,
|
||||||
"auth_token": authToken,
|
"auth_token": authToken,
|
||||||
"status": "updated",
|
"status": "updated",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new agent
|
agentID := uuid.New().String()
|
||||||
agent := NodeAgent{
|
resources := NodeResources{
|
||||||
ID: uuid.New().String(),
|
CPU: CPUResources{Cores: 4},
|
||||||
Name: req.Name,
|
Memory: MemoryResources{
|
||||||
Hostname: req.Hostname,
|
Total: 8 * 1024 * 1024 * 1024, // 8GB
|
||||||
IPAddress: req.IPAddress,
|
Available: 8 * 1024 * 1024 * 1024,
|
||||||
Port: req.Port,
|
},
|
||||||
Status: "connecting",
|
Storage: StorageResources{
|
||||||
Capabilities: req.Capabilities,
|
Total: 100 * 1024 * 1024 * 1024, // 100GB
|
||||||
Resources: NodeResources{
|
Available: 100 * 1024 * 1024 * 1024,
|
||||||
CPU: CPUResources{
|
},
|
||||||
Cores: 4,
|
Network: NetworkResources{
|
||||||
Allocation: 0,
|
Interfaces: []NetworkInterface{
|
||||||
Usage: 0,
|
{
|
||||||
},
|
Name: "eth0",
|
||||||
Memory: MemoryResources{
|
IPAddress: req.IPAddress,
|
||||||
Total: 8 * 1024 * 1024 * 1024, // 8GB
|
MACAddress: "00:00:00:00:00:00",
|
||||||
Allocated: 0,
|
Speed: 1000,
|
||||||
Used: 0,
|
Status: "up",
|
||||||
Available: 8 * 1024 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
Storage: StorageResources{
|
|
||||||
Total: 100 * 1024 * 1024 * 1024, // 100GB
|
|
||||||
Allocated: 0,
|
|
||||||
Used: 0,
|
|
||||||
Available: 100 * 1024 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
Network: NetworkResources{
|
|
||||||
Interfaces: []NetworkInterface{
|
|
||||||
{
|
|
||||||
Name: "eth0",
|
|
||||||
IPAddress: req.IPAddress,
|
|
||||||
MACAddress: "00:00:00:00:00:00",
|
|
||||||
Speed: 1000,
|
|
||||||
Status: "up",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Bandwidth: BandwidthInfo{
|
|
||||||
Inbound: 0,
|
|
||||||
Outbound: 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
LastHeartbeat: time.Now(),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
Metadata: make(map[string]interface{}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.Create(&agent).Error; err != nil {
|
_, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{
|
||||||
|
ID: agentID,
|
||||||
|
Name: req.Name,
|
||||||
|
Hostname: req.Hostname,
|
||||||
|
IpAddress: req.IPAddress,
|
||||||
|
Port: int32(req.Port),
|
||||||
|
Status: sql.NullString{String: "connecting", Valid: true},
|
||||||
|
Version: sql.NullString{},
|
||||||
|
Capabilities: rawJSON(req.Capabilities),
|
||||||
|
Resources: rawJSON(resources),
|
||||||
|
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
Metadata: rawJSON(map[string]interface{}{}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agentID,
|
||||||
"auth_token": authToken,
|
"auth_token": authToken,
|
||||||
"status": "registered",
|
"status": "registered",
|
||||||
})
|
})
|
||||||
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
|||||||
|
|
||||||
// GetAgents returns all registered agents
|
// GetAgents returns all registered agents
|
||||||
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
|
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
|
||||||
var agents []NodeAgent
|
rows, err := h.q.ListAgents(c.Request.Context())
|
||||||
if err := h.db.Find(&agents).Error; err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agents := make([]NodeAgent, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
agents = append(agents, agentFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"agents": agents})
|
c.JSON(http.StatusOK, gin.H{"agents": agents})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAgent returns a specific agent
|
// GetAgent returns a specific agent
|
||||||
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
|
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
|
||||||
id := c.Param("id")
|
row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
var agent NodeAgent
|
if err == sql.ErrNoRows {
|
||||||
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"agent": agent})
|
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAgent updates an agent's information
|
// UpdateAgent updates an agent's information
|
||||||
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var agent NodeAgent
|
row, err := h.q.GetAgent(ctx, id)
|
||||||
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
|
agent := agentFromRow(row)
|
||||||
|
applyAgentUpdates(&agent, updates)
|
||||||
|
|
||||||
|
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
|
||||||
|
ID: agent.ID,
|
||||||
|
Name: agent.Name,
|
||||||
|
Hostname: agent.Hostname,
|
||||||
|
IpAddress: agent.IPAddress,
|
||||||
|
Port: int32(agent.Port),
|
||||||
|
Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""},
|
||||||
|
Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""},
|
||||||
|
Capabilities: rawJSON(agent.Capabilities),
|
||||||
|
Resources: rawJSON(agent.Resources),
|
||||||
|
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
|
||||||
|
Metadata: rawJSON(agent.Metadata),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"agent": agent})
|
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
|
||||||
|
func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
|
||||||
|
remarshal := func(key string, dst interface{}) {
|
||||||
|
if v, ok := updates[key]; ok {
|
||||||
|
if b, err := json.Marshal(v); err == nil {
|
||||||
|
_ = json.Unmarshal(b, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remarshal("name", &agent.Name)
|
||||||
|
remarshal("hostname", &agent.Hostname)
|
||||||
|
remarshal("ip_address", &agent.IPAddress)
|
||||||
|
remarshal("port", &agent.Port)
|
||||||
|
remarshal("status", &agent.Status)
|
||||||
|
remarshal("version", &agent.Version)
|
||||||
|
remarshal("capabilities", &agent.Capabilities)
|
||||||
|
remarshal("resources", &agent.Resources)
|
||||||
|
remarshal("metadata", &agent.Metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent removes an agent
|
// DeleteAgent removes an agent
|
||||||
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
|
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
|
||||||
id := c.Param("id")
|
if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
|
||||||
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
|||||||
heartbeat.Timestamp = time.Now()
|
heartbeat.Timestamp = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent NodeAgent
|
ctx := c.Request.Context()
|
||||||
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
|
if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update agent status and resources
|
if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
|
||||||
agent.Status = heartbeat.Status
|
ID: heartbeat.NodeAgentID,
|
||||||
agent.Resources = heartbeat.Resources
|
Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
|
||||||
agent.LastHeartbeat = heartbeat.Timestamp
|
Resources: rawJSON(heartbeat.Resources),
|
||||||
agent.UpdatedAt = time.Now()
|
LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
|
||||||
|
}); err != nil {
|
||||||
if err := h.db.Save(&agent).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
record := AgentHeartbeatRecord{
|
err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
NodeAgentID: heartbeat.NodeAgentID,
|
NodeAgentID: heartbeat.NodeAgentID,
|
||||||
Timestamp: heartbeat.Timestamp,
|
Timestamp: heartbeat.Timestamp,
|
||||||
Status: heartbeat.Status,
|
Status: heartbeat.Status,
|
||||||
Resources: heartbeat.Resources,
|
Resources: rawJSON(heartbeat.Resources).RawMessage,
|
||||||
ContainerCount: heartbeat.ContainerCount,
|
ContainerCount: int32(heartbeat.ContainerCount),
|
||||||
SystemLoad: heartbeat.SystemLoad,
|
SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
|
||||||
Uptime: heartbeat.Uptime,
|
Uptime: heartbeat.Uptime,
|
||||||
Version: heartbeat.Version,
|
Version: heartbeat.Version,
|
||||||
CreatedAt: time.Now(),
|
})
|
||||||
}
|
if err != nil && !isMissingTableError(err) {
|
||||||
if err := h.db.Create(&record).Error; err != nil {
|
|
||||||
// Keep heartbeat endpoint available even if history table is not yet migrated.
|
// Keep heartbeat endpoint available even if history table is not yet migrated.
|
||||||
if !isMissingTableError(err) {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agentID := c.Param("id")
|
rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
|
||||||
var commands []AgentCommand
|
if err != nil {
|
||||||
if err := h.db.
|
|
||||||
Where("node_agent_id = ? AND status = ?", agentID, "pending").
|
|
||||||
Order("created_at ASC").
|
|
||||||
Limit(25).
|
|
||||||
Find(&commands).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commands := make([]AgentCommand, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
commands = append(commands, commandFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var command AgentCommand
|
ctx := c.Request.Context()
|
||||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
|
||||||
if err == gorm.ErrRecordNotFound {
|
ID: commandID,
|
||||||
|
NodeAgentID: agentID,
|
||||||
|
Status: sql.NullString{String: req.Status, Valid: true},
|
||||||
|
Result: sql.NullString{String: req.Result, Valid: req.Result != ""},
|
||||||
|
Error: sql.NullString{String: req.Error, Valid: req.Error != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
command.Status = req.Status
|
|
||||||
command.UpdatedAt = now
|
|
||||||
command.CompletedAt = &now
|
|
||||||
if req.Result != "" {
|
|
||||||
command.Result = &req.Result
|
|
||||||
}
|
|
||||||
if req.Error != "" {
|
|
||||||
command.Error = &req.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.db.Save(&command).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if command.ContainerID != nil {
|
out := commandFromRow(command)
|
||||||
h.updateContainerStatusAfterCommand(command)
|
if out.ContainerID != nil {
|
||||||
|
h.updateContainerStatusAfterCommand(ctx, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
c.JSON(http.StatusOK, gin.H{"command": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
|
||||||
var container ContainerInstance
|
row, err := h.q.GetContainer(ctx, *command.ContainerID)
|
||||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
container := containerFromRow(row)
|
||||||
|
|
||||||
if command.Status == "failed" {
|
if command.Status == "failed" {
|
||||||
errorMessage := ""
|
errorMessage := ""
|
||||||
if command.Error != nil {
|
if command.Error != nil {
|
||||||
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
|
|||||||
}
|
}
|
||||||
container.Status.State = "error"
|
container.Status.State = "error"
|
||||||
container.Status.Error = &errorMessage
|
container.Status.Error = &errorMessage
|
||||||
_ = h.db.Save(&container).Error
|
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||||
|
ID: container.ID,
|
||||||
|
Status: rawJSON(container.Status),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
|
|||||||
container.Status.State = "removed"
|
container.Status.State = "removed"
|
||||||
container.Status.FinishedAt = &now
|
container.Status.FinishedAt = &now
|
||||||
}
|
}
|
||||||
container.UpdatedAt = now
|
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
|
||||||
_ = h.db.Save(&container).Error
|
ID: container.ID,
|
||||||
|
Status: rawJSON(container.Status),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAgentContainers returns containers running on a specific agent
|
// GetAgentContainers returns containers running on a specific agent
|
||||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
var containers []ContainerInstance
|
|
||||||
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
containers := make([]ContainerInstance, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
containers = append(containers, containerFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"containers": containers})
|
c.JSON(http.StatusOK, gin.H{"containers": containers})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Verify agent exists
|
// Verify agent exists
|
||||||
var agent NodeAgent
|
if _, err := h.q.GetAgent(ctx, agentID); err != nil {
|
||||||
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
|
if err == sql.ErrNoRows {
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
containerID := uuid.New().String()
|
||||||
container := ContainerInstance{
|
container := ContainerInstance{
|
||||||
ID: uuid.New().String(),
|
ID: containerID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Image: req.Image,
|
Image: req.Image,
|
||||||
ProjectID: req.ProjectID,
|
ProjectID: req.ProjectID,
|
||||||
ServiceID: req.ServiceID,
|
ServiceID: req.ServiceID,
|
||||||
NodeAgentID: agentID,
|
NodeAgentID: agentID,
|
||||||
Status: ContainerStatus{
|
Status: ContainerStatus{State: "created", Health: "none"},
|
||||||
State: "created",
|
|
||||||
Health: "none",
|
|
||||||
},
|
|
||||||
Resources: req.Resources,
|
Resources: req.Resources,
|
||||||
Ports: req.Ports,
|
Ports: req.Ports,
|
||||||
Environment: req.Environment,
|
Environment: req.Environment,
|
||||||
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
|
|||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.Create(&container).Error; err != nil {
|
_, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{
|
||||||
|
ID: container.ID,
|
||||||
|
Name: container.Name,
|
||||||
|
Image: container.Image,
|
||||||
|
ProjectID: container.ProjectID,
|
||||||
|
ServiceID: container.ServiceID,
|
||||||
|
NodeAgentID: container.NodeAgentID,
|
||||||
|
Status: rawJSON(container.Status),
|
||||||
|
Resources: rawJSON(container.Resources),
|
||||||
|
Ports: rawJSON(container.Ports),
|
||||||
|
Environment: rawJSON(container.Environment),
|
||||||
|
Volumes: rawJSON(container.Volumes),
|
||||||
|
Networks: rawJSON(container.Networks),
|
||||||
|
RestartPolicy: rawJSON(container.RestartPolicy),
|
||||||
|
HealthCheck: rawJSON(container.HealthCheck),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create command to start container on agent
|
// Create command to start container on agent
|
||||||
command := AgentCommand{
|
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: "create_container",
|
Type: "create_container",
|
||||||
NodeAgentID: agentID,
|
NodeAgentID: agentID,
|
||||||
ContainerID: &container.ID,
|
ContainerID: sql.NullString{String: container.ID, Valid: true},
|
||||||
Payload: map[string]interface{}{
|
Payload: rawJSON(map[string]interface{}{"container": container}),
|
||||||
"container": container,
|
})
|
||||||
},
|
if err != nil {
|
||||||
Status: "pending",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.db.Create(&command).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
command := AgentCommand{
|
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
NodeAgentID: agentID,
|
NodeAgentID: agentID,
|
||||||
Payload: req.Payload,
|
Payload: rawJSON(req.Payload),
|
||||||
Status: "pending",
|
})
|
||||||
CreatedAt: time.Now(),
|
if err != nil {
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.db.Create(&command).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"command": command})
|
c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAgentCommands returns commands for an agent
|
// GetAgentCommands returns commands for an agent
|
||||||
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
|
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
var commands []AgentCommand
|
|
||||||
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commands := make([]AgentCommand, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
commands = append(commands, commandFromRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCommandStatus returns the status of a specific command
|
// GetCommandStatus returns the status of a specific command
|
||||||
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
|
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
|
||||||
agentID := c.Param("id")
|
command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
|
||||||
commandID := c.Param("commandId")
|
ID: c.Param("commandId"),
|
||||||
|
NodeAgentID: c.Param("id"),
|
||||||
var command AgentCommand
|
})
|
||||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerAction handles container lifecycle actions
|
// ContainerAction handles container lifecycle actions
|
||||||
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Verify container exists
|
// Verify container exists
|
||||||
var container ContainerInstance
|
containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
|
||||||
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
|
ID: containerID,
|
||||||
if err == gorm.ErrRecordNotFound {
|
NodeAgentID: agentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create command for the action
|
// Create command for the action
|
||||||
command := AgentCommand{
|
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Type: fmt.Sprintf("%s_container", action),
|
Type: fmt.Sprintf("%s_container", action),
|
||||||
NodeAgentID: agentID,
|
NodeAgentID: agentID,
|
||||||
ContainerID: &container.ID,
|
ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
|
||||||
Payload: map[string]interface{}{
|
Payload: rawJSON(map[string]interface{}{
|
||||||
"container_id": containerID,
|
"container_id": containerID,
|
||||||
"container_name": container.Name,
|
"container_name": containerRow.Name,
|
||||||
"docker_name": container.Name,
|
"docker_name": containerRow.Name,
|
||||||
},
|
}),
|
||||||
Status: "pending",
|
})
|
||||||
CreatedAt: time.Now(),
|
if err != nil {
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.db.Create(&command).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var agent NodeAgent
|
ctx := c.Request.Context()
|
||||||
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
|
agentRow, err := h.q.GetAgent(ctx, agentID)
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
agent := agentFromRow(agentRow)
|
||||||
|
|
||||||
from := time.Now().Add(-duration)
|
from := time.Now().Add(-duration)
|
||||||
var records []AgentHeartbeatRecord
|
records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
|
||||||
queryErr := h.db.
|
NodeAgentID: agentID,
|
||||||
Where("node_agent_id = ? AND timestamp >= ?", agentID, from).
|
Timestamp: from,
|
||||||
Order("timestamp ASC").
|
})
|
||||||
Find(&records).Error
|
|
||||||
if queryErr != nil && !isMissingTableError(queryErr) {
|
if queryErr != nil && !isMissingTableError(queryErr) {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
|
||||||
return
|
return
|
||||||
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
|
|||||||
|
|
||||||
metrics := make([]map[string]interface{}, 0, len(records))
|
metrics := make([]map[string]interface{}, 0, len(records))
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount))
|
var resources NodeResources
|
||||||
|
var load SystemLoad
|
||||||
|
_ = json.Unmarshal(record.Resources, &resources)
|
||||||
|
_ = json.Unmarshal(record.SystemLoad, &load)
|
||||||
|
metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(metrics) == 0 {
|
if len(metrics) == 0 {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,21 +14,14 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/lib/pq"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Constants for validation and limits
|
// Default quota limits applied when the request omits them.
|
||||||
const (
|
const (
|
||||||
MaxServiceNameLength = 100
|
DefaultRPMLimit = 60
|
||||||
MaxRoutePrefixLength = 200
|
DefaultMonthlyQuota = 1000
|
||||||
MaxUpstreamURLLength = 500
|
|
||||||
MaxAPIKeyNameLength = 100
|
|
||||||
MinAPIKeyLength = 20
|
|
||||||
MaxAPIKeyLength = 100
|
|
||||||
DefaultRPMLimit = 60
|
|
||||||
DefaultMonthlyQuota = 1000
|
|
||||||
MaxRPMLimit = 10000
|
|
||||||
MaxMonthlyQuota = 10000000
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validator instance for request validation
|
// Validator instance for request validation
|
||||||
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
|
|||||||
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateServiceID generates a cryptographically secure service ID
|
|
||||||
func generateServiceID() (string, error) {
|
|
||||||
bytes := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return "svc_" + hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateAPIKey generates a cryptographically secure API key
|
// generateAPIKey generates a cryptographically secure API key
|
||||||
func generateAPIKey() (string, error) {
|
func generateAPIKey() (string, error) {
|
||||||
bytes := make([]byte, 32)
|
bytes := make([]byte, 32)
|
||||||
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pqErr *pq.Error
|
||||||
|
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPwhyServiceValidate probes the upstream health path and records the
|
||||||
|
// result on the service row. Ported from the standalone APwhy server.
|
||||||
|
func handleAPwhyServiceValidate(c *gin.Context) {
|
||||||
|
serviceID := c.Param("id")
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
var upstreamURL, healthPath string
|
||||||
|
var authHeader, authValue sql.NullString
|
||||||
|
var timeoutMs sql.NullInt64
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
||||||
|
FROM api_services WHERE id = $1
|
||||||
|
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 8000
|
||||||
|
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||||
|
timeout = int(timeoutMs.Int64)
|
||||||
|
}
|
||||||
|
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||||
|
if authHeader.Valid && authHeader.String != "" {
|
||||||
|
req.Header.Set(authHeader.String, authValue.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
|
||||||
|
res, probeErr := client.Do(req)
|
||||||
|
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
message := ""
|
||||||
|
ok := false
|
||||||
|
if probeErr != nil {
|
||||||
|
message = probeErr.Error()
|
||||||
|
} else {
|
||||||
|
defer res.Body.Close()
|
||||||
|
status = res.StatusCode
|
||||||
|
message = fmt.Sprintf("HTTP %d", res.StatusCode)
|
||||||
|
ok = res.StatusCode >= 200 && res.StatusCode < 300
|
||||||
|
}
|
||||||
|
|
||||||
|
validationStatus := "failed"
|
||||||
|
if ok {
|
||||||
|
validationStatus = "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = db.ExecContext(ctx, `
|
||||||
|
UPDATE api_services
|
||||||
|
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`, validationStatus, message, serviceID)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
_, _ = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
||||||
|
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
||||||
|
`, serviceID, message, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||||
|
"validation": map[string]interface{}{
|
||||||
|
"ok": ok,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleAPwhyServicesList returns a list of API services
|
// handleAPwhyServicesList returns a list of API services
|
||||||
func handleAPwhyServicesList(c *gin.Context) {
|
func handleAPwhyServicesList(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var services []map[string]interface{}
|
services := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||||
var enabled bool
|
var enabled int
|
||||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip malformed rows
|
continue // Skip malformed rows
|
||||||
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
|
|||||||
"slug": slug,
|
"slug": slug,
|
||||||
"upstreamUrl": upstreamURL,
|
"upstreamUrl": upstreamURL,
|
||||||
"routePrefix": routePrefix,
|
"routePrefix": routePrefix,
|
||||||
"enabled": enabled,
|
"enabled": enabled != 0,
|
||||||
"createdAt": createdAt,
|
"createdAt": createdAt,
|
||||||
"updatedAt": updatedAt,
|
"updatedAt": updatedAt,
|
||||||
})
|
})
|
||||||
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Generate slug and ID
|
|
||||||
id, err := generateServiceID()
|
|
||||||
if err != nil {
|
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
|
||||||
"Failed to generate service ID", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
||||||
|
|
||||||
// Insert service into database
|
|
||||||
query := `
|
|
||||||
INSERT INTO api_services (
|
|
||||||
id, name, slug, upstream_url, route_prefix, health_path,
|
|
||||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
|
|
||||||
`
|
|
||||||
|
|
||||||
var rpmLimit, monthlyQuota int
|
var rpmLimit, monthlyQuota int
|
||||||
if req.RPMLimit != nil {
|
if req.RPMLimit != nil {
|
||||||
rpmLimit = *req.RPMLimit
|
rpmLimit = *req.RPMLimit
|
||||||
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
|||||||
monthlyQuota = DefaultMonthlyQuota
|
monthlyQuota = DefaultMonthlyQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||||
req.RoutePrefix, rpmLimit, monthlyQuota)
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO api_services (
|
||||||
|
name, slug, upstream_url, route_prefix, health_path,
|
||||||
|
rpm_limit, monthly_quota, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
|
||||||
|
"A service with this slug or route prefix already exists", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||||
"Failed to create service", err.Error())
|
"Failed to create service", err.Error())
|
||||||
return
|
return
|
||||||
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
|
enabled := 0
|
||||||
|
if *input.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
_, err := db.ExecContext(context.Background(),
|
_, err := db.ExecContext(context.Background(),
|
||||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||||
*input.Enabled, serviceID)
|
enabled, serviceID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var keys []map[string]interface{}
|
keys := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||||
var enabled bool
|
var enabled int
|
||||||
var rpmLimit, monthlyQuota sql.NullInt64
|
var rpmLimit, monthlyQuota sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||||
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
|
|||||||
"name": name,
|
"name": name,
|
||||||
"keyPrefix": keyPrefix,
|
"keyPrefix": keyPrefix,
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
"enabled": enabled,
|
"enabled": enabled != 0,
|
||||||
"createdAt": createdAt,
|
"createdAt": createdAt,
|
||||||
"updatedAt": updatedAt,
|
"updatedAt": updatedAt,
|
||||||
}
|
}
|
||||||
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ID and prefix
|
|
||||||
id, err := generateServiceID()
|
|
||||||
if err != nil {
|
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
|
||||||
"Failed to generate key ID", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPrefix := apiKey[:8]
|
keyPrefix := apiKey[:8]
|
||||||
|
|
||||||
// Set default limits based on plan
|
// Set default limits based on plan
|
||||||
var rpmLimit, monthlyQuota int
|
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
|
||||||
switch input.Plan {
|
if err != nil {
|
||||||
case "free":
|
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
rpmLimit, monthlyQuota = 60, 1000
|
"Invalid plan", err.Error())
|
||||||
case "pro":
|
return
|
||||||
rpmLimit, monthlyQuota = 600, 50000
|
|
||||||
case "business":
|
|
||||||
rpmLimit, monthlyQuota = 3000, 300000
|
|
||||||
default:
|
|
||||||
rpmLimit, monthlyQuota = 60, 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert key into database
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||||
_, err = db.ExecContext(context.Background(), `
|
var id string
|
||||||
|
err = db.QueryRowContext(context.Background(), `
|
||||||
INSERT INTO api_keys (
|
INSERT INTO api_keys (
|
||||||
id, name, key_hash, key_prefix, plan, allowed_service_ids,
|
name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
rpm_limit, monthly_quota, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
||||||
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
|
RETURNING id
|
||||||
|
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
|||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": input.Name,
|
"name": input.Name,
|
||||||
"plan": input.Plan,
|
"plan": plan,
|
||||||
"key": apiKey, // Only return the actual key once
|
"key": apiKey, // Only return the actual key once
|
||||||
"keyPrefix": keyPrefix,
|
"keyPrefix": keyPrefix,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
|
enabled := 0
|
||||||
|
if *input.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
_, err := db.ExecContext(context.Background(),
|
_, err := db.ExecContext(context.Background(),
|
||||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||||
*input.Enabled, keyID)
|
enabled, keyID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// firstPathParam returns the first non-empty route param from the provided names.
|
// firstPathParam returns the first non-empty route param from the provided names.
|
||||||
func firstPathParam(c *gin.Context, names ...string) string {
|
func firstPathParam(c *gin.Context, names ...string) string {
|
||||||
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty returns the first non-blank string, trimmed.
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -17,8 +17,6 @@ import (
|
|||||||
"containr/internal/scaling"
|
"containr/internal/scaling"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||||
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Initialize scaling handler
|
// Initialize scaling handler
|
||||||
scalingHandler := NewScalingHandler(autoScaler)
|
scalingHandler := NewScalingHandler(autoScaler)
|
||||||
|
|
||||||
// Initialize GORM for agent system
|
// Initialize agent handler (sqlc-backed)
|
||||||
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
|
agentHandler := NewNodeAgentHandler(db)
|
||||||
if err != nil {
|
|
||||||
panic("Failed to initialize GORM: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize agent handler
|
|
||||||
agentHandler := NewNodeAgentHandler(gormDB)
|
|
||||||
|
|
||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
@@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
c.Set("ha_manager", haManager)
|
c.Set("ha_manager", haManager)
|
||||||
c.Set("scaling_handler", scalingHandler)
|
c.Set("scaling_handler", scalingHandler)
|
||||||
c.Set("gorm_db", gormDB)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Audit Logs routes
|
// Audit Logs routes
|
||||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
}
|
|
||||||
|
|
||||||
// APwhy Gateway routes
|
// API Gateway routes (merged APwhy) - namespaced to avoid
|
||||||
apwhy := router.Group("/api/v1")
|
// colliding with Containr's own /services paths.
|
||||||
{
|
gateway := protected.Group("/gateway")
|
||||||
// Health check (no auth required)
|
{
|
||||||
apwhy.GET("/health", func(c *gin.Context) {
|
gateway.GET("/services", handleAPwhyServicesList)
|
||||||
c.JSON(200, gin.H{
|
gateway.POST("/services", handleAPwhyServicesCreate)
|
||||||
"ok": true,
|
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||||
"data": gin.H{
|
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
|
||||||
"status": "ok",
|
|
||||||
"name": "Containr + APwhy",
|
|
||||||
"database": "postgresql",
|
|
||||||
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protected APwhy routes (authentication required)
|
gateway.GET("/keys", handleAPwhyKeysList)
|
||||||
protectedAPwhy := router.Group("/api/v1")
|
gateway.POST("/keys", handleAPwhyKeysCreate)
|
||||||
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
|
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
||||||
{
|
|
||||||
// Service management
|
|
||||||
protectedAPwhy.GET("/services", handleAPwhyServicesList)
|
|
||||||
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
|
|
||||||
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
|
|
||||||
|
|
||||||
// API Keys
|
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
||||||
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
|
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
||||||
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
|
}
|
||||||
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
|
||||||
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>APwhy</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||||
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||||
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||||
Type: serviceType,
|
Type: sql.NullString{String: serviceType, Valid: true},
|
||||||
Status: "stopped",
|
Status: sql.NullString{String: "stopped", Valid: true},
|
||||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package analytics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
APIKey string
|
|
||||||
WebsiteID string
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
APIKey: apiKey,
|
|
||||||
WebsiteID: websiteID,
|
|
||||||
HTTP: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Enabled() bool {
|
|
||||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return map[string]any{
|
|
||||||
"enabled": false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
|
||||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
|
||||||
u.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload["enabled"] = true
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,321 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"containr/internal/apwhy/config"
|
|
||||||
"containr/internal/apwhy/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type apiErrorPayload struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiResponsePayload struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
Error apiErrorPayload `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cfg := config.Load()
|
|
||||||
cfg.SQLitePath = ":memory:"
|
|
||||||
cfg.CookieSecure = false
|
|
||||||
|
|
||||||
store, err := storage.Open(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open test storage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := NewServer(store, cfg)
|
|
||||||
ts := httptest.NewServer(server.Handler())
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
ts.Close()
|
|
||||||
_ = store.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
jar, err := cookiejar.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create cookie jar: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ts, &http.Client{Jar: jar}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var bodyReader *bytes.Reader
|
|
||||||
if body == "" {
|
|
||||||
bodyReader = bytes.NewReader(nil)
|
|
||||||
} else {
|
|
||||||
bodyReader = bytes.NewReader([]byte(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(method, url, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
if body != "" {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var payload apiResponsePayload
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
||||||
t.Fatalf("failed to decode response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.StatusCode, payload
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/bootstrap/register-owner",
|
|
||||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/auth/login",
|
|
||||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
|
|
||||||
ts, authClient := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
// Bootstrap should be open before any user exists.
|
|
||||||
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
bootstrapData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
|
|
||||||
t.Fatalf("expected hasUsers=false before owner bootstrap")
|
|
||||||
}
|
|
||||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
|
|
||||||
t.Fatalf("expected registrationOpen=true before owner bootstrap")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protected route without auth should fail.
|
|
||||||
anonClient := &http.Client{}
|
|
||||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register owner and login.
|
|
||||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
|
||||||
|
|
||||||
// Bootstrap should close once owner exists.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
bootstrapData, ok = payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
|
|
||||||
t.Fatalf("expected hasUsers=true after owner bootstrap")
|
|
||||||
}
|
|
||||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
|
|
||||||
t.Fatalf("expected registrationOpen=false after owner bootstrap")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owner session cookie should authorize /auth/me.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
meData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if email, _ := meData["email"].(string); email != "[email protected]" {
|
|
||||||
t.Fatalf("unexpected /auth/me email: %q", email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout should revoke auth session.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerServicesCreateValidationRules(t *testing.T) {
|
|
||||||
ts, client := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
bootstrapAndLoginOwner(t, client, baseURL)
|
|
||||||
|
|
||||||
// Route prefix under /api must be rejected.
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusBadRequest || payload.OK {
|
|
||||||
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
if payload.Error.Code != "ROUTE_CONFLICT" {
|
|
||||||
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid service should be created successfully.
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
createData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if _, ok := createData["id"].(string); !ok {
|
|
||||||
t.Fatalf("expected created service id in response")
|
|
||||||
}
|
|
||||||
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
|
|
||||||
t.Fatalf("expected normalized service slug, got %q", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List should contain the newly created service.
|
|
||||||
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
rows, ok := payload.Data.([]any)
|
|
||||||
if !ok || len(rows) == 0 {
|
|
||||||
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerProxyWithAPIKey(t *testing.T) {
|
|
||||||
ts, authClient := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("x-api-key") != "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = io.WriteString(w, "unexpected-api-key-header")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
|
|
||||||
}))
|
|
||||||
t.Cleanup(upstream.Close)
|
|
||||||
|
|
||||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
|
||||||
|
|
||||||
// Create service exposed at /web/* and proxied to the upstream test server.
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
authClient,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint API key for gateway access.
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
authClient,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/keys",
|
|
||||||
`{"name":"integration-key","plan":"free"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
keyData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
rawKey, _ := keyData["key"].(string)
|
|
||||||
if strings.TrimSpace(rawKey) == "" {
|
|
||||||
t.Fatalf("expected non-empty raw API key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// No API key should be rejected.
|
|
||||||
anonClient := &http.Client{}
|
|
||||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
if payload.Error.Code != "API_KEY_MISSING" {
|
|
||||||
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid API key should proxy request and strip the key before forwarding.
|
|
||||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create proxy request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("x-api-key", rawKey)
|
|
||||||
|
|
||||||
resp, err := anonClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("proxy request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read proxy response body: %v", err)
|
|
||||||
}
|
|
||||||
body := string(bodyBytes)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if body != "ok:/ping?x=1" {
|
|
||||||
t.Fatalf("unexpected proxy body: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user