Skip to content
Open
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
23 changes: 22 additions & 1 deletion internal/api/v1/application/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ func Create(c *gin.Context) apierror.APIErrors {
return apierror.NewMultiError(theIssues)
}

if createRequest.Configuration.Processes != nil {
for _, issue := range application.ValidateProcesses(*createRequest.Configuration.Processes) {
theIssues = append(theIssues, apierror.NewBadRequestError(issue.Error()))
}
if len(theIssues) > 0 {
return apierror.NewMultiError(theIssues)
}
}

var routes []string
if createRequest.Configuration.Routes != nil {
// Note: Routes can be empty here!
Expand All @@ -115,6 +124,11 @@ func Create(c *gin.Context) apierror.APIErrors {
}
routes = []string{route}
}
if createRequest.Configuration.Processes != nil {
if issue := application.ValidateProcessRoutes(*createRequest.Configuration.Processes, routes); issue != nil {
return apierror.NewBadRequestError(issue.Error())
}
}

apierr := validateRoutes(ctx, cluster, appRef.Name, appRef.Namespace, routes)
if apierr != nil {
Expand All @@ -139,7 +153,7 @@ func Create(c *gin.Context) apierror.APIErrors {
// Arguments found OK, now we can modify the system state

err = application.Create(ctx, cluster, appRef, username, routes, chart,
createRequest.Configuration.Settings)
createRequest.Configuration.Settings, processValue(createRequest.Configuration.Processes))
if err != nil {
return apierror.InternalError(err)
}
Expand Down Expand Up @@ -172,6 +186,13 @@ func Create(c *gin.Context) apierror.APIErrors {
return nil
}

func processValue(processes *models.ApplicationProcesses) models.ApplicationProcesses {
if processes == nil {
return nil
}
return *processes
}

func validateRoutes(ctx context.Context, cluster *kubernetes.Cluster, appName, namespace string, desiredRoutes []string) apierror.APIErrors {
desiredRoutesMap := map[string]struct{}{}
for _, desiredRoute := range desiredRoutes {
Expand Down
49 changes: 48 additions & 1 deletion internal/api/v1/application/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package application

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -88,13 +89,36 @@ func Update(c *gin.Context) apierror.APIErrors { // nolint:gocyclo // simplifica
len(updateRequest.Settings) == 0 &&
updateRequest.Configurations == nil &&
updateRequest.Routes == nil &&
updateRequest.AppChart == "" {
updateRequest.AppChart == "" &&
updateRequest.Processes == nil {

log.Infow("updating app -- no changes")
response.OK(c)
return nil
}

if updateRequest.Processes != nil {
if issues := application.ValidateProcesses(*updateRequest.Processes); len(issues) > 0 {
apiIssues := make([]apierror.APIError, 0, len(issues))
for _, issue := range issues {
apiIssues = append(apiIssues, apierror.NewBadRequestError(issue.Error()))
}
return apierror.NewMultiError(apiIssues)
}
}

effectiveProcesses := app.Configuration.Processes
if updateRequest.Processes != nil {
effectiveProcesses = *updateRequest.Processes
}
effectiveRoutes := app.Configuration.Routes
if updateRequest.Routes != nil {
effectiveRoutes = updateRequest.Routes
}
if issue := application.ValidateProcessRoutes(effectiveProcesses, effectiveRoutes); issue != nil {
return apierror.NewBadRequestError(issue.Error())
}

if app.Workload != nil {
// For a running application we have to validate changed custom chart values against
// the configured app chart. It has to be done first, this ensures that there will
Expand Down Expand Up @@ -199,6 +223,13 @@ func Update(c *gin.Context) apierror.APIErrors { // nolint:gocyclo // simplifica
}
}

if updateRequest.Processes != nil {
log.Infow("updating app", "processes", *updateRequest.Processes)
if err := updateProcesses(ctx, client, namespace, appName, *updateRequest.Processes); err != nil {
return apierror.InternalError(err)
}
}

// backward compatibility: if no flag provided then restart the app
restart := updateRequest.Restart == nil || *updateRequest.Restart
if restart {
Expand Down Expand Up @@ -375,3 +406,19 @@ func updateChartValueSettings(
_, err := client.Namespace(namespace).Patch(ctx, appName, types.JSONPatchType, []byte(patch), metav1.PatchOptions{})
return err
}

func updateProcesses(
ctx context.Context,
client dynamic.NamespaceableResourceInterface,
namespace string,
appName string,
processes models.ApplicationProcesses,
) error {
value, err := json.Marshal(processes)
if err != nil {
return err
}
patch := fmt.Sprintf(`[{"op":"add","path":"/spec/processes","value":%s}]`, value)
_, err = client.Namespace(namespace).Patch(ctx, appName, types.JSONPatchType, []byte(patch), metav1.PatchOptions{})
return err
}
1 change: 1 addition & 0 deletions internal/api/v1/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func deployApp(ctx context.Context, cluster *kubernetes.Cluster, app models.AppR
Domains: domains,
Start: start,
Settings: appObj.Configuration.Settings,
Processes: appObj.Configuration.Processes,
}

log.Infow("deploying app", "namespace", app.Namespace, "app", app.Name)
Expand Down
47 changes: 47 additions & 0 deletions internal/application/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func Create(
routes []string,
chart string,
settings models.ChartValueSettings,
processes models.ApplicationProcesses,
) error {
client, err := cluster.ClientApp()
if err != nil {
Expand All @@ -178,6 +179,15 @@ func Create(
if err != nil {
return err
}
if processes != nil {
processData, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&processes)
if err != nil {
return err
}
if err := unstructured.SetNestedMap(u, processData, "spec", "processes"); err != nil {
return err
}
}
us := &unstructured.Unstructured{Object: u}
us.SetAPIVersion("application.epinio.io/v1")
us.SetKind("App")
Expand Down Expand Up @@ -1028,6 +1038,28 @@ func Settings(app *unstructured.Unstructured) (models.ChartValueSettings, error)
return settings, nil
}

// Processes returns the release-scoped process definitions stored on the app.
// A missing field preserves the existing single-process application behavior.
func Processes(app *unstructured.Unstructured) (models.ApplicationProcesses, error) {
processData, found, err := unstructured.NestedMap(
app.UnstructuredContent(),
"spec",
"processes",
)
if err != nil {
return nil, errors.Wrap(err, "processes should be an object")
}
if !found {
return nil, nil
}

processes := models.ApplicationProcesses{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(processData, &processes); err != nil {
return nil, errors.Wrap(err, "decoding processes")
}
return processes, nil
}

/*
StageID returns the stage ID of the last attempt at staging, if one exists.
It returns an empty string otherwise. The information is pulled out of the
Expand Down Expand Up @@ -1609,6 +1641,11 @@ func aggregate(ctx context.Context,
return nil, errors.Wrap(err, "finding settings")
}

processes, err := Processes(&appCR)
if err != nil {
return nil, errors.Wrap(err, "finding processes")
}

desiredRoutes, err := DesiredRoutes(&appCR)
if err != nil {
return nil, errors.Wrap(err, "finding desired routes")
Expand All @@ -1628,6 +1665,7 @@ func aggregate(ctx context.Context,
app.Configuration.Routes = desiredRoutes
app.Configuration.AppChart = chartName
app.Configuration.Settings = settings
app.Configuration.Processes = processes
app.Origin = origin
app.StageID = stageID
app.ImageURL = imageURL
Expand Down Expand Up @@ -1799,6 +1837,14 @@ func fetch(ctx context.Context, cluster *kubernetes.Cluster, app *models.App) er
return err
}

processes, err := Processes(applicationCR)
if err != nil {
err = errors.Wrap(err, "finding processes")
app.StatusMessage = err.Error()
app.Status = models.ApplicationError
return err
}

app.Meta.CreatedAt = applicationCR.GetCreationTimestamp()

app.Configuration.Instances = &instances
Expand All @@ -1809,6 +1855,7 @@ func fetch(ctx context.Context, cluster *kubernetes.Cluster, app *models.App) er
app.Configuration.Routes = desiredRoutes
app.Configuration.AppChart = chartName
app.Configuration.Settings = settings
app.Configuration.Processes = processes
app.Origin = origin
app.StageID = stageID
app.ImageURL = imageURL
Expand Down
96 changes: 96 additions & 0 deletions internal/application/processes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright © 2026 SUSE LLC
// Licensed under the Apache License, Version 2.0.

package application

import (
"fmt"

"github.com/epinio/epinio/pkg/api/core/v1/models"
"k8s.io/apimachinery/pkg/util/validation"
)

// ValidateProcesses validates the deliberately small multi-process POC model.
// Kubernetes remains responsible for validating the cron schedule itself.
func ValidateProcesses(processes models.ApplicationProcesses) []error {
var issues []error
routeProcesses := 0
releaseProcesses := 0

for name, process := range processes {
if problems := validation.IsDNS1123Label(name); len(problems) > 0 {
issues = append(issues, fmt.Errorf("process %q has an invalid name: %s", name, problems[0]))
}
if len(process.Command) == 0 {
issues = append(issues, fmt.Errorf("process %q must define a command", name))
}

kind := process.Kind
if kind == "" {
kind = models.ApplicationProcessDeployment
}

switch kind {
case models.ApplicationProcessDeployment:
if process.Replicas != nil && *process.Replicas < 0 {
issues = append(issues, fmt.Errorf("process %q replicas must be zero or greater", name))
}
if process.Schedule != "" {
issues = append(issues, fmt.Errorf("deployment process %q cannot define a schedule", name))
}
case models.ApplicationProcessCron:
if process.Schedule == "" {
issues = append(issues, fmt.Errorf("cron process %q must define a schedule", name))
}
if process.Replicas != nil {
issues = append(issues, fmt.Errorf("cron process %q cannot define replicas", name))
}
if process.Routes {
issues = append(issues, fmt.Errorf("cron process %q cannot receive routes", name))
}
case models.ApplicationProcessRelease:
releaseProcesses++
if process.Replicas != nil {
issues = append(issues, fmt.Errorf("release process %q cannot define replicas", name))
}
if process.Schedule != "" {
issues = append(issues, fmt.Errorf("release process %q cannot define a schedule", name))
}
if process.Routes {
issues = append(issues, fmt.Errorf("release process %q cannot receive routes", name))
}
default:
issues = append(issues, fmt.Errorf("process %q has unknown kind %q", name, process.Kind))
}

if process.Routes {
routeProcesses++
}
}

if routeProcesses > 1 {
issues = append(issues, fmt.Errorf("only one deployment process may receive application routes"))
}
if releaseProcesses > 1 {
issues = append(issues, fmt.Errorf("only one release process is supported"))
}

return issues
}

// ValidateProcessRoutes ensures that external application routes have a
// process Service to target. A nil process map is the legacy single-process
// model and remains valid for backward compatibility.
func ValidateProcessRoutes(processes models.ApplicationProcesses, routes []string) error {
if processes == nil || len(routes) == 0 {
return nil
}

for _, process := range processes {
if process.Routes {
return nil
}
}

return fmt.Errorf("application routes require one deployment process with routes enabled")
}
Loading