Skip to content
Closed
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
31 changes: 30 additions & 1 deletion cli/internal/simulation/simulation.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,24 @@ func (s *SimulationServer) CopyDir(dst, src string) error {
func (s *SimulationServer) startServices(output io.Writer) (<-chan service.ServiceEvent, error) {
serviceIntents := s.appSpec.ServiceIntents

servicePorts := make(map[string]netx.ReservedPort)
eventChans := []<-chan service.ServiceEvent{}

for serviceName, serviceIntent := range serviceIntents {
// Helper function for lazy port allocation
getOrAllocatePort := func(serviceName string) (netx.ReservedPort, error) {
if port, exists := servicePorts[serviceName]; exists {
return port, nil
}
port, err := netx.GetNextPort()
if err != nil {
return 0, err
}
servicePorts[serviceName] = port
return port, nil
}

for serviceName, serviceIntent := range serviceIntents {
port, err := getOrAllocatePort(serviceName)
if err != nil {
return nil, err
}
Expand All @@ -304,6 +318,21 @@ func (s *SimulationServer) startServices(output io.Writer) (<-chan service.Servi
}
}

// Inject URLs for services this service can access
// Check which services grant access to this service and lazily allocate their ports
for targetServiceName, targetServiceIntent := range serviceIntents {
if access, ok := targetServiceIntent.GetAccess(); ok {
if _, hasAccess := access[serviceName]; hasAccess {
targetPort, err := getOrAllocatePort(targetServiceName)
if err != nil {
return nil, err
}
envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if these names should be prefixed with SUGA

intentCopy.Env[envVarName] = fmt.Sprintf("http://localhost:%d", targetPort)
}
Comment on lines +330 to +332
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Match production-safe env key naming

Simulation still emits PAYMENTS-API_URL, while Terraform (after fixing) will emit PAYMENTS_API_URL. Services would read different env vars locally vs. deployed. Please apply the same hyphen-to-underscore normalization here so developers see identical keys.

Apply this diff:

-					envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
+					safeName := strings.ToUpper(strings.ReplaceAll(targetServiceName, "-", "_"))
+					envVarName := fmt.Sprintf("%s_URL", safeName)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
intentCopy.Env[envVarName] = fmt.Sprintf("http://localhost:%d", targetPort)
}
safeName := strings.ToUpper(strings.ReplaceAll(targetServiceName, "-", "_"))
envVarName := fmt.Sprintf("%s_URL", safeName)
intentCopy.Env[envVarName] = fmt.Sprintf("http://localhost:%d", targetPort)
}
🤖 Prompt for AI Agents
In cli/internal/simulation/simulation.go around lines 330 to 332, the env var
key is built with strings.ToUpper(targetServiceName) producing names like
PAYMENTS-API_URL; change the normalization to replace hyphens with underscores
before uppercasing so keys become PAYMENTS_API_URL (e.g. use
strings.ReplaceAll(targetServiceName, "-", "_") wrapped with strings.ToUpper
when constructing the fmt.Sprintf pattern) and keep the existing "_URL" suffix.

}
}

simulatedService, eventChan, err := service.NewServiceSimulation(serviceName, intentCopy, port, s.apiPort)
if err != nil {
return nil, err
Expand Down
2 changes: 2 additions & 0 deletions cli/pkg/schema/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ type ResourceType string
const (
Database ResourceType = "database"
Bucket ResourceType = "bucket"
Service ResourceType = "service"
)

const allAccess = "all"

var validActions = map[ResourceType][]string{
Database: {"query", "mutate"},
Bucket: {"read", "write", "delete"},
Service: {"invoke"},
}

func GetValidActions(resourceType ResourceType) []string {
Expand Down
126 changes: 126 additions & 0 deletions cli/pkg/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,3 +597,129 @@ func TestApplication_IsValid_WithSubtypes(t *testing.T) {
violations = app.IsValid(WithRequireSubtypes())
assert.Len(t, violations, 0, "Expected no violations with RequireSubtypes option, got: %v", violations)
}

func TestApplication_IsValid_ServiceAccess_Valid(t *testing.T) {
app := &Application{
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"api": {
Container: Container{
Docker: &Docker{Dockerfile: "Dockerfile"},
},
},
"user_service": {
Container: Container{
Docker: &Docker{Dockerfile: "Dockerfile"},
},
Access: map[string][]string{
"api": {"invoke"}, // user_service grants api access to invoke it
},
},
},
}

violations := app.IsValid()
assert.Len(t, violations, 0, "Expected no violations for valid service access, got: %v", violations)
}

func TestApplication_IsValid_ServiceAccess_InvalidAction(t *testing.T) {
app := &Application{
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"api": {
Container: Container{
Docker: &Docker{Dockerfile: "Dockerfile"},
},
},
"user_service": {
Container: Container{
Docker: &Docker{Dockerfile: "Dockerfile"},
},
Access: map[string][]string{
"api": {"read", "write"}, // Invalid actions for service
},
},
},
}

violations := app.IsValid()
assert.NotEmpty(t, violations, "Expected violations for invalid service actions")

errString := FormatValidationErrors(GetSchemaValidationErrors(violations))
assert.Contains(t, errString, "user_service:")
assert.Contains(t, errString, "Invalid service actions: read, write")
assert.Contains(t, errString, "Valid actions are: invoke")
}

func TestApplication_IsValid_ServiceAccess_NonExistentAccessor(t *testing.T) {
app := &Application{
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"user_service": {
Container: Container{
Docker: &Docker{Dockerfile: "Dockerfile"},
},
Access: map[string][]string{
"non_existent": {"invoke"}, // Accessor service doesn't exist
},
},
},
}

violations := app.IsValid()
assert.NotEmpty(t, violations, "Expected violations for non-existent accessor service")

errString := FormatValidationErrors(GetSchemaValidationErrors(violations))
assert.Contains(t, errString, "user_service:")
assert.Contains(t, errString, "grants access to non-existent service non_existent")
}

func TestApplicationFromYaml_ServiceAccess_TargetBased(t *testing.T) {
yaml := `
name: test-app
description: A test application with service access
target: team/platform@1
services:
api:
container:
docker:
dockerfile: Dockerfile
user_service:
container:
docker:
dockerfile: Dockerfile
access:
api:
- invoke
payment_service:
container:
docker:
dockerfile: Dockerfile
access:
api:
- invoke
user_service:
- invoke
`

app, result, err := ApplicationFromYaml(yaml)
assert.NoError(t, err)
assert.True(t, result.Valid(), "Expected valid result, got validation errors: %v", result.Errors())

violations := app.IsValid()
assert.Len(t, violations, 0, "Expected no violations for valid target-based service access, got: %v", violations)

// Verify the access configuration
userService := app.ServiceIntents["user_service"]
assert.NotNil(t, userService.Access)
assert.Contains(t, userService.Access, "api")
assert.Equal(t, []string{"invoke"}, userService.Access["api"])

paymentService := app.ServiceIntents["payment_service"]
assert.NotNil(t, paymentService.Access)
assert.Contains(t, paymentService.Access, "api")
assert.Contains(t, paymentService.Access, "user_service")
}
6 changes: 6 additions & 0 deletions cli/pkg/schema/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ type ServiceIntent struct {
Dev *Dev `json:"dev,omitempty" yaml:"dev,omitempty"`

Schedules []*Schedule `json:"schedules,omitempty" yaml:"schedules,omitempty"`

Access map[string][]string `json:"access,omitempty" yaml:"access,omitempty"`
}

func (s *ServiceIntent) GetType() string {
return "service"
}

func (s *ServiceIntent) GetAccess() (map[string][]string, bool) {
return s.Access, true
}

type Schedule struct {
Cron string `json:"cron" yaml:"cron" jsonschema:"required"`
Path string `json:"path" yaml:"path" jsonschema:"required"`
Expand Down
21 changes: 21 additions & 0 deletions cli/pkg/schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ func (a *Application) checkAccessPermissions() []gojsonschema.ResultError {
}
}

for name, intent := range a.ServiceIntents {
if access, ok := intent.GetAccess(); ok {
for accessorServiceName, actions := range access {
// Validate actions
invalidActions, ok := ValidateActions(actions, Service)
if !ok {
key := fmt.Sprintf("services.%s.access.%s", name, accessorServiceName)
err := fmt.Sprintf("Invalid service %s: %s. Valid actions are: %s", pluralise("action", len(invalidActions)), strings.Join(invalidActions, ", "), strings.Join(GetValidActions(Service), ", "))
violations = append(violations, newValidationError(key, err))
}

// Validate that the accessor service exists
if _, exists := a.ServiceIntents[accessorServiceName]; !exists {
key := fmt.Sprintf("services.%s.access.%s", name, accessorServiceName)
err := fmt.Sprintf("Service %s grants access to non-existent service %s", name, accessorServiceName)
violations = append(violations, newValidationError(key, err))
}
}
}
}

return violations
}

Expand Down
68 changes: 64 additions & 4 deletions engines/terraform/resource_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,42 @@ func (td *TerraformDeployment) processServiceIdentities(appSpec *app_spec_schema
return serviceInputs, nil
}

func (td *TerraformDeployment) collectServiceAccessors(appSpec *app_spec_schema.Application) map[string]map[string]interface{} {
serviceAccessors := make(map[string]map[string]interface{})

for targetServiceName, targetServiceIntent := range appSpec.ServiceIntents {
if access, ok := targetServiceIntent.GetAccess(); ok {
accessors := map[string]interface{}{}

for accessorServiceName, actions := range access {
expandedActions := app_spec_schema.ExpandActions(actions, app_spec_schema.Service)
idMap := td.serviceIdentities[accessorServiceName]

accessors[accessorServiceName] = map[string]interface{}{
"actions": jsii.Strings(expandedActions...),
"identities": idMap,
}
}

if len(accessors) > 0 {
serviceAccessors[targetServiceName] = accessors
}
}
}

return serviceAccessors
}

func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema.Application, serviceInputs map[string]*SugaServiceVariables, serviceEnvs map[string][]interface{}) error {
serviceAccessors := td.collectServiceAccessors(appSpec)

// Track original env values before modification
originalEnvs := make(map[string]interface{})
for intentName := range appSpec.ServiceIntents {
sugaVar := serviceInputs[intentName]
originalEnvs[intentName] = sugaVar.Env
}

for intentName, serviceIntent := range appSpec.ServiceIntents {
spec, err := td.engine.platform.GetResourceBlueprint(serviceIntent.GetType(), serviceIntent.GetSubType())
if err != nil {
Expand All @@ -105,11 +140,10 @@ func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema.
}

sugaVar := serviceInputs[intentName]
origEnv := sugaVar.Env

mergedEnv := serviceEnvs[intentName]
allEnv := append(mergedEnv, origEnv)
sugaVar.Env = cdktf.Fn_Merge(&allEnv)
if accessors, ok := serviceAccessors[intentName]; ok {
sugaVar.Services = accessors
}

td.createVariablesForIntent(intentName, spec)

Expand All @@ -121,6 +155,32 @@ func (td *TerraformDeployment) processServiceResources(appSpec *app_spec_schema.
})
}

// Add service to service urls
// Build reverse index: for each accessor service, find which targets grant it access
for accessorServiceName := range appSpec.ServiceIntents {
for targetServiceName, targetServiceIntent := range appSpec.ServiceIntents {
if access, ok := targetServiceIntent.GetAccess(); ok {
if _, hasAccess := access[accessorServiceName]; hasAccess {
if targetResource, ok := td.terraformResources[targetServiceName]; ok {
envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint"))
serviceEnvs[accessorServiceName] = append(serviceEnvs[accessorServiceName], map[string]interface{}{
envVarName: httpEndpoint,
})
Comment on lines +165 to +169
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize service URL env keys

Service names like payments-api now emit PAYMENTS-API_URL, which many runtimes (e.g. AWS Lambda/ECS) reject because env keys must use only letters, digits, and underscores. Please normalize the name (replace - with _) before uppercasing so deployments don’t fail for valid service names, and mirror the same transformation in simulation to keep parity.

Apply this diff:

-					envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
+					safeName := strings.ToUpper(strings.ReplaceAll(targetServiceName, "-", "_"))
+					envVarName := fmt.Sprintf("%s_URL", safeName)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
envVarName := fmt.Sprintf("%s_URL", strings.ToUpper(targetServiceName))
httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint"))
serviceEnvs[accessorServiceName] = append(serviceEnvs[accessorServiceName], map[string]interface{}{
envVarName: httpEndpoint,
})
safeName := strings.ToUpper(strings.ReplaceAll(targetServiceName, "-", "_"))
envVarName := fmt.Sprintf("%s_URL", safeName)
httpEndpoint := targetResource.Get(jsii.String("suga.http_endpoint"))
serviceEnvs[accessorServiceName] = append(serviceEnvs[accessorServiceName], map[string]interface{}{
envVarName: httpEndpoint,
})
🤖 Prompt for AI Agents
In engines/terraform/resource_handler.go around lines 165 to 169, the code
constructs env var names by uppercasing the service name but does not sanitize
characters like hyphens, producing invalid keys such as PAYMENTS-API_URL;
replace non-alphanumeric characters (at least '-' -> '_') in targetServiceName
before uppercasing and appending _URL, e.g. normalizeName :=
strings.ToUpper(strings.ReplaceAll(targetServiceName, "-", "_")), use that for
envVarName, and ensure the identical normalization is applied in the simulation
codepath so runtime and simulation generate the same sanitized env keys.

}
}
}
}
}

// Merge environment variables for all services
for intentName := range appSpec.ServiceIntents {
sugaVar := serviceInputs[intentName]
mergedEnv := serviceEnvs[intentName]
allEnv := append(mergedEnv, originalEnvs[intentName])
sugaVar.Env = cdktf.Fn_Merge(&allEnv)
}

return nil
}

Expand Down
1 change: 1 addition & 0 deletions engines/terraform/suga.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type SugaServiceVariables struct {
ImageId *string `json:"image_id"`
Env interface{} `json:"env"`
Identities *map[string]interface{} `json:"identities"`
Services map[string]interface{} `json:"services,omitempty"`
Schedules *map[string]SugaServiceSchedule `json:"schedules,omitempty"`
StackId *string `json:"stack_id"`
}
Expand Down
Loading