Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
},
"[markdown]": {
"files.exclude": {}
}
},
"go.diagnostic.vulncheck": "Off"
// "markdown.validate.ignoredLinks": ["**/.github/copilot-instructions.md"]
// "yaml.schemas": {
// "./schema/lokstra.json": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package dbpool_manager
package dbpool_crud

import (
"context"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package dbpool_manager_test
package dbpool_crud_test

import (
"context"
"testing"

"github.com/primadi/lokstra/common/dbpool_manager"
"github.com/primadi/lokstra/common/dbpool_crud"
"github.com/primadi/lokstra/lokstra_init"
)

Expand All @@ -14,7 +14,7 @@ func ExampleAddDbPool() {
lokstra_init.UsePgxDbPoolManager(true) // Enable distributed sync

// Add new pool
err := dbpool_manager.AddDbPool(dbpool_manager.DbPoolConfig{
err := dbpool_crud.AddDbPool(dbpool_crud.DbPoolConfig{
Name: "db-analytics",
DSN: "postgres://user:pass@localhost:5432/analytics",
Schema: "public",
Expand All @@ -25,15 +25,15 @@ func ExampleAddDbPool() {
}

// Pool is now available across all servers (if using distributed sync)
conn, _ := dbpool_manager.AcquireDbConn(context.Background(), "db-analytics")
conn, _ := dbpool_crud.AcquireDbConn(context.Background(), "db-analytics")
defer conn.Release()

// Use connection...
}

// Example: Update existing pool configuration
func ExampleUpdateDbPool() {
err := dbpool_manager.UpdateDbPool(dbpool_manager.DbPoolConfig{
err := dbpool_crud.UpdateDbPool(dbpool_crud.DbPoolConfig{
Name: "db-main",
DSN: "postgres://user:pass@new-host:5432/main",
Schema: "public",
Expand All @@ -49,7 +49,7 @@ func ExampleUpdateDbPool() {

// Example: Remove a pool
func ExampleRemoveDbPool() {
err := dbpool_manager.RemoveDbPool("db-old-tenant")
err := dbpool_crud.RemoveDbPool("db-old-tenant")
if err != nil {
panic(err)
}
Expand All @@ -59,20 +59,20 @@ func ExampleRemoveDbPool() {

// Example: List all pools
func ExampleListDbPools() {
pools, err := dbpool_manager.ListDbPools()
pools, err := dbpool_crud.ListDbPools()
if err != nil {
panic(err)
}

for _, poolName := range pools {
info, _ := dbpool_manager.GetDbPoolInfo(poolName)
info, _ := dbpool_crud.GetDbPoolInfo(poolName)
println("Pool:", info.Name, "Schema:", info.Schema)
}
}

// Example: Get pool info
func ExampleGetDbPoolInfo() {
info, err := dbpool_manager.GetDbPoolInfo("db-main")
info, err := dbpool_crud.GetDbPoolInfo("db-main")
if err != nil {
panic(err)
}
Expand All @@ -83,7 +83,7 @@ func ExampleGetDbPoolInfo() {

// Example: Direct pool access
func ExampleGetDbPool() {
pool, err := dbpool_manager.GetDbPool("db-main")
pool, err := dbpool_crud.GetDbPool("db-main")
if err != nil {
panic(err)
}
Expand All @@ -100,7 +100,7 @@ func TestDbPoolCRUD(t *testing.T) {
lokstra_init.UsePgxDbPoolManager(false) // Use local sync for testing

// Create
err := dbpool_manager.AddDbPool(dbpool_manager.DbPoolConfig{
err := dbpool_crud.AddDbPool(dbpool_crud.DbPoolConfig{
Name: "test-pool",
DSN: "postgres://localhost/test",
Schema: "test_schema",
Expand All @@ -110,7 +110,7 @@ func TestDbPoolCRUD(t *testing.T) {
}

// Read
info, err := dbpool_manager.GetDbPoolInfo("test-pool")
info, err := dbpool_crud.GetDbPoolInfo("test-pool")
if err != nil {
t.Fatalf("Failed to get pool info: %v", err)
}
Expand All @@ -122,7 +122,7 @@ func TestDbPoolCRUD(t *testing.T) {
}

// Update
err = dbpool_manager.UpdateDbPool(dbpool_manager.DbPoolConfig{
err = dbpool_crud.UpdateDbPool(dbpool_crud.DbPoolConfig{
Name: "test-pool",
DSN: "postgres://localhost/test2",
Schema: "test_schema2",
Expand All @@ -131,19 +131,19 @@ func TestDbPoolCRUD(t *testing.T) {
t.Fatalf("Failed to update pool: %v", err)
}

info, _ = dbpool_manager.GetDbPoolInfo("test-pool")
info, _ = dbpool_crud.GetDbPoolInfo("test-pool")
if info.Schema != "test_schema2" {
t.Errorf("Expected schema 'test_schema2', got '%s'", info.Schema)
}

// Delete
err = dbpool_manager.RemoveDbPool("test-pool")
err = dbpool_crud.RemoveDbPool("test-pool")
if err != nil {
t.Fatalf("Failed to remove pool: %v", err)
}

// Verify deletion
_, err = dbpool_manager.GetDbPoolInfo("test-pool")
_, err = dbpool_crud.GetDbPoolInfo("test-pool")
if err == nil {
t.Error("Expected error when getting deleted pool")
}
Expand Down
32 changes: 21 additions & 11 deletions core/deploy/loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"strings"

"github.com/primadi/lokstra/common/utils"
"github.com/primadi/lokstra/core/deploy/loader/internal"
"github.com/primadi/lokstra/core/deploy/loader/resolver"
"github.com/primadi/lokstra/core/deploy/schema"
"github.com/xeipuuv/gojsonschema"
Expand Down Expand Up @@ -73,6 +72,16 @@ func loadConfig(paths ...string) (*schema.DeployConfig, error) {
}
}

// STEP 1.5: Validate merged config BEFORE any resolution
// This catches schema errors early before decode filters them out
mergedBytes, err := yaml.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged config for validation: %w", err)
}
if err := ValidateConfigYAML(mergedBytes); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}

// STEP 2: Normalize shorthand servers (must be before getting server key)
normalizeShorthandServers(merged)

Expand Down Expand Up @@ -122,11 +131,6 @@ func loadConfig(paths ...string) (*schema.DeployConfig, error) {
// STEP 9: Normalize server definitions (convert helper fields to apps)
normalizeServerDefinitions(&finalConfig)

// STEP 10: Validate final config
if err := ValidateConfig(&finalConfig); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}

return &finalConfig, nil
}

Expand Down Expand Up @@ -248,15 +252,21 @@ func mergeMaps[T any](target, source map[string]*T) map[string]*T {
return result
}

// ValidateConfig validates a deployment configuration against JSON schema
func ValidateConfig(config *schema.DeployConfig) error {
// ValidateConfigYAML validates raw YAML bytes against JSON schema
// This must be called BEFORE YAML decode to catch invalid fields
func ValidateConfigYAML(yamlData []byte) error {
// Load embedded schema from schema package
schemaData := schema.GetSchemaBytes()
schemaLoader := gojsonschema.NewBytesLoader(schemaData)

// Convert config to map for validation
configMap := internal.ConfigToMap(config)
documentLoader := gojsonschema.NewGoLoader(configMap) // Validate
// Parse YAML to generic map (don't use strict struct decode)
var configMap map[string]any
if err := yaml.Unmarshal(yamlData, &configMap); err != nil {
return fmt.Errorf("failed to parse YAML: %w", err)
}

// Validate against schema
documentLoader := gojsonschema.NewGoLoader(configMap)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("validation error: %w", err)
Expand Down
138 changes: 138 additions & 0 deletions core/deploy/loader/test/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,141 @@ func TestSmartMerging(t *testing.T) {
t.Errorf("expected helper published-services to be cleared, got %d items", len(apiServer.HelperPublishedServices))
}
}

func TestValidateConfigYAML_Valid(t *testing.T) {
validYAML := `
configs:
app-name: test-app
db-host: localhost

service-definitions:
user-service:
type: user-service-factory
depends-on:
- db-pool

deployments:
development:
servers:
api:
base-url: http://localhost:8080
apps:
- addr: ":8080"
published-services:
- user-service
`

err := loader.ValidateConfigYAML([]byte(validYAML))
if err != nil {
t.Errorf("valid YAML should pass validation: %v", err)
}
}

func TestValidateConfigYAML_InvalidField(t *testing.T) {
invalidYAML := `
configs:
app-name: test-app

servise-definitions:
user-service:
type: user-service-factory
`

err := loader.ValidateConfigYAML([]byte(invalidYAML))
if err == nil {
t.Error("expected validation error for invalid field 'servise-definitions'")
}
}

func TestValidateConfigYAML_MissingRequiredField(t *testing.T) {
// Service definition without 'type' field
invalidYAML := `
service-definitions:
user-service:
depends-on:
- db-pool
`

err := loader.ValidateConfigYAML([]byte(invalidYAML))
if err == nil {
t.Error("expected validation error for missing 'type' field")
}
}

func TestValidateConfigYAML_InvalidType(t *testing.T) {
// addr should be string, not number
invalidYAML := `
deployments:
development:
servers:
api:
base-url: http://localhost:8080
apps:
- addr: 8080
published-services:
- user-service
`

err := loader.ValidateConfigYAML([]byte(invalidYAML))
if err == nil {
t.Error("expected validation error for wrong type (number instead of string)")
}
}

func TestValidateConfigYAML_InvalidDependsOn(t *testing.T) {
// depends-on with invalid service name pattern
invalidYAML := `
service-definitions:
user-service:
type: user-service-factory
depends-on:
- DB_POOL
`

err := loader.ValidateConfigYAML([]byte(invalidYAML))
if err == nil {
t.Error("expected validation error for invalid depends-on pattern (uppercase with underscore)")
}
}

func TestValidateConfigYAML_MalformedYAML(t *testing.T) {
malformedYAML := `
configs:
app-name: test-app
invalid yaml:
- missing proper indentation
- broken structure
`

err := loader.ValidateConfigYAML([]byte(malformedYAML))
if err == nil {
t.Error("expected error for malformed YAML")
}
}

func TestValidateConfigYAML_AdditionalProperties(t *testing.T) {
// Unknown top-level field
invalidYAML := `
configs:
app-name: test-app

unknown-field:
something: value
`

err := loader.ValidateConfigYAML([]byte(invalidYAML))
if err == nil {
t.Error("expected validation error for unknown top-level field")
}
}

func TestValidateConfigYAML_EmptyConfig(t *testing.T) {
emptyYAML := `{}`

err := loader.ValidateConfigYAML([]byte(emptyYAML))
// Empty config might be valid depending on schema requirements
// Adjust assertion based on actual schema rules
if err != nil {
t.Logf("Empty config validation: %v", err)
}
}
16 changes: 8 additions & 8 deletions core/deploy/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,18 @@ type ConfigDef struct {

// MiddlewareDef defines a middleware instance
type MiddlewareDef struct {
Name string `yaml:"name"`
Type string `yaml:"type"` // Factory type
Config map[string]any `yaml:"config"` // Optional config
Name string `yaml:"name,omitempty" json:"name,omitempty"` // Optional: defaults to map key
Type string `yaml:"type" json:"type"` // Factory type
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` // Optional config
}

// ServiceDef defines a service instance
type ServiceDef struct {
Name string `yaml:"name"`
Type string `yaml:"type"` // Factory type
DependsOn []string `yaml:"depends-on"` // Dependencies (can be "paramName:serviceName")
Router *RouterDef `yaml:"router,omitempty"` // Embedded router definition (auto-generated router for this service)
Config map[string]any `yaml:"config"` // Optional config
Name string `yaml:"name,omitempty" json:"name,omitempty"` // Optional: defaults to map key
Type string `yaml:"type" json:"type"` // Factory type
DependsOn []string `yaml:"depends-on,omitempty" json:"depends-on,omitempty"` // Dependencies (can be "paramName:serviceName")
Router *RouterDef `yaml:"router,omitempty" json:"router,omitempty"` // Embedded router definition (auto-generated router for this service)
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` // Optional config
}

// ReverseProxyDef defines a reverse proxy configuration
Expand Down
Loading