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:
Tomas Dvorak committed 2026-09-17 09:40:08 +02:00
1 parent 37a56dc6b6
commit 9e3aa3aa17
153 files changed
+6989 -11128

No files matched your search

-1
View File
@@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \
WORKDIR /app
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=auth-builder /auth ./auth
+4 -5
View File
@@ -170,8 +170,7 @@ internal/
├── config/ - Configuration management
├── database/ - Database connections and migrations
└── middleware/ - HTTP middleware
migrations/ - SQL migration files
migrations_goose/ - Goose-managed migrations for new schema changes
migrations_goose/ - Goose-managed migrations (single migration path)
```
### Adding New Endpoints
@@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes
1. Create handler functions in `internal/api/`
2. Add routes in `internal/api/routes.go`
3. Update database schema:
- Legacy chain lives in `migrations/` and is executed first.
- New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`).
- Keep legacy files immutable after they are shipped.
- All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`).
- `20260401163000_baseline.sql` carries the consolidated schema; append new files after it.
- Never edit the baseline in place; always add a new migration file.
### SQLC (Type-Safe Queries)
-29
View File
@@ -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)
}
}
+4 -19
View File
@@ -25,11 +25,6 @@ func main() {
}
defer db.Close()
legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR")
if legacyDir == "" {
legacyDir = "migrations"
}
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
if gooseDir == "" {
gooseDir = "migrations_goose"
@@ -41,24 +36,14 @@ func main() {
}
switch command {
case "up":
case "up", "goose-up":
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()
log.Fatalf("Migration failed: %v", err)
}
migrationCancel()
log.Println("Legacy + goose 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")
log.Println("Migrations completed successfully")
case "goose-status":
if err := db.GooseStatus(gooseDir); err != nil {
log.Fatalf("Goose status failed: %v", err)
@@ -73,6 +58,6 @@ func main() {
}
log.Printf("Created goose migration %q in %q", name, gooseDir)
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)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func main() {
// Run startup migrations unless explicitly disabled.
if cfg.AutoMigrate {
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()
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!")
}
-32
View File
@@ -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
View File
@@ -8,11 +8,9 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1
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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72
github.com/pressly/goose/v3 v3.24.1
@@ -22,13 +20,9 @@ require (
github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1
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 (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.9.1 // 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/distribution/reference v0.6.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/fsnotify/fsnotify v1.9.0 // 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/goccy/go-json v0.10.2 // 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/klauspost/cpuid/v2 v2.2.4 // 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/reflect2 v1.0.2 // 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/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // 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/sethvargo/go-retry v0.3.0 // 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.yaml.in/yaml/v3 v3.0.4 // 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/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
@@ -106,7 +91,5 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)
-40
View File
@@ -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/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
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-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-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/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/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/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
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/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/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=
-74
View File
@@ -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
View File
@@ -1,7 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"math"
"net/http"
@@ -9,26 +12,29 @@ import (
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/sqlc-dev/pqtype"
)
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Hostname string `json:"hostname" gorm:"not null"`
IPAddress string `json:"ip_address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
Status string `json:"status" gorm:"default:'offline'"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
CreatedAt time.Time `json:"created_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
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
// ContainerInstance represents a container running on an agent
type ContainerInstance struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Image string `json:"image" gorm:"not null"`
ProjectID string `json:"project_id" gorm:"not null"`
ServiceID string `json:"service_id" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
Status ContainerStatus `json:"status" gorm:"serializer:json"`
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
Environment map[string]string `json:"environment" gorm:"serializer:json"`
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
Networks []string `json:"networks" gorm:"serializer:json"`
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
NodeAgentID string `json:"node_agent_id"`
Status ContainerStatus `json:"status"`
Resources ContainerResources `json:"resources"`
Ports []PortMapping `json:"ports"`
Environment map[string]string `json:"environment"`
Volumes []VolumeMount `json:"volumes"`
Networks []string `json:"networks"`
RestartPolicy RestartPolicy `json:"restart_policy"`
HealthCheck *HealthCheck `json:"health_check"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -155,12 +161,12 @@ type HealthCheck struct {
// AgentCommand represents a command sent to an agent
type AgentCommand struct {
ID string `json:"id" gorm:"primaryKey"`
Type string `json:"type" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
ID string `json:"id"`
Type string `json:"type"`
NodeAgentID string `json:"node_agent_id"`
ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
Status string `json:"status" gorm:"default:'pending'"`
Payload map[string]interface{} `json:"payload"`
Status string `json:"status"`
Result *string `json:"result"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
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 {
Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"`
@@ -206,13 +195,125 @@ type SystemLoad struct {
// NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct {
db *gorm.DB
q *sqlcdb.Queries
}
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db}
func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
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
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct {
@@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Check if agent already exists
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()
ctx := c.Request.Context()
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"})
return
}
c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID,
"agent_id": updated.ID,
"auth_token": authToken,
"status": "updated",
})
return
}
// Create new agent
agent := NodeAgent{
ID: uuid.New().String(),
Name: req.Name,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
Port: req.Port,
Status: "connecting",
Capabilities: req.Capabilities,
Resources: NodeResources{
CPU: CPUResources{
Cores: 4,
Allocation: 0,
Usage: 0,
},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Allocated: 0,
Used: 0,
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,
agentID := uuid.New().String()
resources := NodeResources{
CPU: CPUResources{Cores: 4},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
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",
},
},
},
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"})
return
}
c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID,
"agent_id": agentID,
"auth_token": authToken,
"status": "registered",
})
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
// GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent
if err := h.db.Find(&agents).Error; err != nil {
rows, err := h.q.ListAgents(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return
}
agents := make([]NodeAgent, 0, len(rows))
for _, row := range rows {
agents = append(agents, agentFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"agents": agents})
}
// GetAgent returns a specific agent
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
}
// UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row, err := h.q.GetAgent(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
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"})
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
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return
}
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
heartbeat.Timestamp = time.Now()
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
return
}
// Update agent status and resources
agent.Status = heartbeat.Status
agent.Resources = heartbeat.Resources
agent.LastHeartbeat = heartbeat.Timestamp
agent.UpdatedAt = time.Now()
if err := h.db.Save(&agent).Error; err != nil {
if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
ID: heartbeat.NodeAgentID,
Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
Resources: rawJSON(heartbeat.Resources),
LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
record := AgentHeartbeatRecord{
err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
ID: uuid.New().String(),
NodeAgentID: heartbeat.NodeAgentID,
Timestamp: heartbeat.Timestamp,
Status: heartbeat.Status,
Resources: heartbeat.Resources,
ContainerCount: heartbeat.ContainerCount,
SystemLoad: heartbeat.SystemLoad,
Resources: rawJSON(heartbeat.Resources).RawMessage,
ContainerCount: int32(heartbeat.ContainerCount),
SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
Uptime: heartbeat.Uptime,
Version: heartbeat.Version,
CreatedAt: time.Now(),
}
if err := h.db.Create(&record).Error; err != nil {
})
if err != nil && !isMissingTableError(err) {
// 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"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return
}
c.Status(http.StatusOK)
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
return
}
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.
Where("node_agent_id = ? AND status = ?", agentID, "pending").
Order("created_at ASC").
Limit(25).
Find(&commands).Error; err != nil {
rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
return
}
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
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"})
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"})
return
}
if command.ContainerID != nil {
h.updateContainerStatusAfterCommand(command)
out := commandFromRow(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) {
var container ContainerInstance
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
row, err := h.q.GetContainer(ctx, *command.ContainerID)
if err != nil {
return
}
container := containerFromRow(row)
if command.Status == "failed" {
errorMessage := ""
if command.Error != nil {
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
}
container.Status.State = "error"
container.Status.Error = &errorMessage
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
return
}
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
container.Status.State = "removed"
container.Status.FinishedAt = &now
}
container.UpdatedAt = now
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
}
// GetAgentContainers returns containers running on a specific agent
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
agentID := c.Param("id")
var containers []ContainerInstance
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return
}
containers := make([]ContainerInstance, 0, len(rows))
for _, row := range rows {
containers = append(containers, containerFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"containers": containers})
}
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
ctx := c.Request.Context()
// Verify agent exists
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if _, err := h.q.GetAgent(ctx, agentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
containerID := uuid.New().String()
container := ContainerInstance{
ID: uuid.New().String(),
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{
State: "created",
Health: "none",
},
ID: containerID,
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{State: "created", Health: "none"},
Resources: req.Resources,
Ports: req.Ports,
Environment: req.Environment,
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
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"})
return
}
// Create command to start container on agent
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "create_container",
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container": container,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
ContainerID: sql.NullString{String: container.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{"container": container}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return
}
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
return
}
command := AgentCommand{
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: req.Type,
NodeAgentID: agentID,
Payload: req.Payload,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
Payload: rawJSON(req.Payload),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusCreated, gin.H{"command": command})
c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)})
}
// GetAgentCommands returns commands for an agent
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
// GetCommandStatus returns the status of a specific command
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
agentID := c.Param("id")
commandID := c.Param("commandId")
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
ID: c.Param("commandId"),
NodeAgentID: c.Param("id"),
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"command": command})
c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
}
// ContainerAction handles container lifecycle actions
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
return
}
ctx := c.Request.Context()
// Verify container exists
var container ContainerInstance
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
ID: containerID,
NodeAgentID: agentID,
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
}
// Create command for the action
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{
"container_id": containerID,
"container_name": container.Name,
"docker_name": container.Name,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
"container_name": containerRow.Name,
"docker_name": containerRow.Name,
}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
return
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
agentRow, err := h.q.GetAgent(ctx, agentID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
agent := agentFromRow(agentRow)
from := time.Now().Add(-duration)
var records []AgentHeartbeatRecord
queryErr := h.db.
Where("node_agent_id = ? AND timestamp >= ?", agentID, from).
Order("timestamp ASC").
Find(&records).Error
records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
NodeAgentID: agentID,
Timestamp: from,
})
if queryErr != nil && !isMissingTableError(queryErr) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
return
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
metrics := make([]map[string]interface{}, 0, len(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 {
+127 -71
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
@@ -13,21 +14,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// Constants for validation and limits
// Default quota limits applied when the request omits them.
const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
)
// Validator instance for request validation
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
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
func generateAPIKey() (string, error) {
bytes := make([]byte, 32)
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
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
func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
}
defer rows.Close()
var services []map[string]interface{}
services := make([]map[string]interface{}, 0)
for rows.Next() {
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)
if err != nil {
continue // Skip malformed rows
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
})
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
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, " ", "-"))
// 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
if req.RPMLimit != nil {
rpmLimit = *req.RPMLimit
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
monthlyQuota = DefaultMonthlyQuota
}
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
req.RoutePrefix, rpmLimit, monthlyQuota)
// id defaults to gen_random_uuid(); enabled defaults to 1.
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 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",
"Failed to create service", err.Error())
return
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID)
enabled, serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
}
defer rows.Close()
var keys []map[string]interface{}
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
}
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
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]
// Set default limits based on plan
var rpmLimit, monthlyQuota int
switch input.Plan {
case "free":
rpmLimit, monthlyQuota = 60, 1000
case "pro":
rpmLimit, monthlyQuota = 600, 50000
case "business":
rpmLimit, monthlyQuota = 3000, 300000
default:
rpmLimit, monthlyQuota = 60, 1000
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
if err != nil {
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid plan", err.Error())
return
}
// Insert key into database
_, err = db.ExecContext(context.Background(), `
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys (
id, name, key_hash, key_prefix, plan, allowed_service_ids,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
"data": gin.H{
"id": id,
"name": input.Name,
"plan": input.Plan,
"plan": plan,
"key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix,
"enabled": true,
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID)
enabled, keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+15 -1
View File
@@ -1,6 +1,10 @@
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.
func firstPathParam(c *gin.Context, names ...string) string {
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
}
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 ""
}
+16 -44
View File
@@ -17,8 +17,6 @@ import (
"containr/internal/scaling"
"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) {
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Initialize agent handler (sqlc-backed)
agentHandler := NewNodeAgentHandler(db)
// Initialize database handler
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("ha_manager", haManager)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next()
})
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
// APwhy Gateway routes
apwhy := router.Group("/api/v1")
{
// Health check (no auth required)
apwhy.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"ok": true,
"data": gin.H{
"status": "ok",
"name": "Containr + APwhy",
"database": "postgresql",
"generatedAt": time.Now().UTC().Format(time.RFC3339),
},
})
})
}
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
// Protected APwhy routes (authentication required)
protectedAPwhy := router.Group("/api/v1")
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
{
// Service management
protectedAPwhy.GET("/services", handleAPwhyServicesList)
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
// API Keys
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
// Analytics
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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
@@ -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>
+2 -2
View File
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: serviceType,
Status: "stopped",
Type: sql.NullString{String: serviceType, Valid: true},
Status: sql.NullString{String: "stopped", Valid: true},
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
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)
}
}
@@ -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
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more