Răsfoiți Sursa

feat: convert yaml to proto type for validation (#3353)

ianedwards 2 ani în urmă
părinte
comite
7313ffaf7b

+ 285 - 0
api/server/handlers/porter_app/conversion/porter_yaml.go

@@ -0,0 +1,285 @@
+package conversion
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/ghodss/yaml"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// PorterStackYAML represents all the possible fields in a Porter YAML file
+// Either Applications or Services/Apps must be defined, depending on if there are multiple apps
+type PorterStackYAML struct {
+	Applications map[string]*Application `yaml:"applications" validate:"required_without=Services Apps"`
+	Name         string                  `yaml:"name" validate:"required_without=Applications"`
+	Version      *string                 `yaml:"version"`
+	Image        *Image                  `yaml:"image"`
+	Build        *Build                  `yaml:"build"`
+	Env          map[string]string       `yaml:"env"`
+	SyncedEnv    []*SyncedEnvSection     `yaml:"synced_env"`
+	Apps         map[string]Service      `yaml:"apps" validate:"required_without=Applications Services"`
+	Services     map[string]Service      `yaml:"services" validate:"required_without=Applications Apps"`
+
+	Release *Service `yaml:"release"`
+}
+
+// Application represents a single app in a Porter YAML file
+type Application struct {
+	Name     string             `yaml:"name"`
+	Services map[string]Service `yaml:"services" validate:"required"`
+	Image    *Image             `yaml:"image"`
+	Build    *Build             `yaml:"build"`
+	Env      map[string]string  `yaml:"env"`
+
+	Release *Service `yaml:"release"`
+}
+
+// Build represents the build settings for a Porter app
+type Build struct {
+	Context    string   `yaml:"context" validate:"dir"`
+	Method     string   `yaml:"method" validate:"required,oneof=pack docker registry"`
+	Builder    string   `yaml:"builder" validate:"required_if=Method pack"`
+	Buildpacks []string `yaml:"buildpacks"`
+	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
+	Image      string   `yaml:"image" validate:"required_if=Method registry"`
+}
+
+// Service represents a single service in a porter app
+type Service struct {
+	Run    string      `yaml:"run"`
+	Config interface{} `yaml:"config"`
+	Type   string      `yaml:"type" validate:"required, oneof=web worker job"`
+}
+
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
+type Image struct {
+	Repository string `yaml:"repository"`
+	Tag        string `yaml:"tag"`
+}
+
+// AppProtoFromYaml converts a Porter YAML file into a map of PorterApp proto objects to validate on CCP
+func AppProtoFromYaml(file []byte) (map[string]*porterv1.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(context.Background(), "convert-apps-from-porter-yaml")
+	defer span.End()
+
+	porterYaml := &PorterStackYAML{}
+	err := yaml.Unmarshal(file, porterYaml)
+	if err != nil {
+		return nil, err
+	}
+
+	// convert each app in the app group into a valid standalone definition
+	apps, err := appsFromApplicationGroup(*porterYaml)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error getting apps from application group")
+	}
+
+	validatedApps := make(map[string]*porterv1.PorterApp)
+	for _, app := range apps {
+		validApp := &porterv1.PorterApp{
+			Name: app.Name,
+			Env:  app.Env,
+			Build: &porterv1.Build{
+				Context:    app.Build.Context,
+				Method:     app.Build.Method,
+				Builder:    app.Build.Builder,
+				Buildpacks: app.Build.Buildpacks,
+				Dockerfile: app.Build.Dockerfile,
+			},
+		}
+
+		services := make(map[string]*porterv1.Service, 0)
+		for name, service := range app.Services {
+			serviceType, err := protoEnumFromType(name, service)
+			if err != nil {
+				return nil, telemetry.Error(ctx, span, err, "error getting service type")
+			}
+
+			validService, err := serviceProtoFromConfig(service, serviceType)
+			if err != nil {
+				return nil, telemetry.Error(ctx, span, err, "error casting service config")
+			}
+
+			services[name] = validService
+		}
+		validApp.Services = services
+
+		if app.Release != nil {
+			release, err := serviceProtoFromConfig(*app.Release, porterv1.ServiceType_SERVICE_TYPE_JOB)
+			if err != nil {
+				return nil, telemetry.Error(ctx, span, err, "error casting release config")
+			}
+			validApp.Release = release
+		}
+
+		validatedApps[app.Name] = validApp
+	}
+
+	return validatedApps, nil
+}
+
+func yamlToApplication(porterYaml PorterStackYAML) (Application, error) {
+	application := Application{}
+
+	var services map[string]Service
+
+	if porterYaml.Services == nil && porterYaml.Apps == nil {
+		return application, fmt.Errorf("no apps or services defined in porter yaml")
+	}
+
+	if porterYaml.Services != nil && porterYaml.Apps != nil {
+		return application, fmt.Errorf("both apps and services defined in porter yaml")
+	}
+
+	if porterYaml.Apps != nil {
+		services = porterYaml.Apps
+	}
+
+	if porterYaml.Services != nil {
+		services = porterYaml.Services
+	}
+
+	application = Application{
+		Name:     porterYaml.Name,
+		Env:      porterYaml.Env,
+		Services: services,
+		Build:    porterYaml.Build,
+		Release:  porterYaml.Release,
+	}
+
+	return application, nil
+}
+
+func protoEnumFromType(name string, service Service) (porterv1.ServiceType, error) {
+	var serviceType porterv1.ServiceType
+
+	if service.Type != "" {
+		if service.Type == "web" {
+			return porterv1.ServiceType_SERVICE_TYPE_WEB, nil
+		}
+		if service.Type == "worker" {
+			return porterv1.ServiceType_SERVICE_TYPE_WORKER, nil
+		}
+		if service.Type == "job" {
+			return porterv1.ServiceType_SERVICE_TYPE_JOB, nil
+		}
+
+		return serviceType, fmt.Errorf("invalid service type '%s'", service.Type)
+	}
+
+	if strings.Contains(name, "web") {
+		return porterv1.ServiceType_SERVICE_TYPE_WEB, nil
+	}
+
+	if strings.Contains(name, "wkr") {
+		return porterv1.ServiceType_SERVICE_TYPE_WORKER, nil
+	}
+
+	return porterv1.ServiceType_SERVICE_TYPE_JOB, nil
+}
+
+func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
+	configYaml, err := yaml.Marshal(service.Config)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to marshal service config: %w", err)
+	}
+
+	configBytes, err := yaml.YAMLToJSON(configYaml)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to convert service config to JSON: %w", err)
+	}
+
+	validSevice := &porterv1.Service{
+		Run:  service.Run,
+		Type: serviceType,
+	}
+
+	if service.Config == nil {
+		return validSevice, nil
+	}
+
+	switch serviceType {
+	case porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED:
+		return nil, fmt.Errorf("Service type unspecified")
+	case porterv1.ServiceType_SERVICE_TYPE_WEB:
+		webConfig := &porterv1.WebServiceConfig{}
+		err := helpers.UnmarshalContractObject(configBytes, webConfig)
+		if err != nil {
+			return nil, fmt.Errorf("error unmarshaling web service config: %w", err)
+		}
+
+		validSevice.Config = &porterv1.Service_WebConfig{
+			WebConfig: webConfig,
+		}
+
+	case porterv1.ServiceType_SERVICE_TYPE_WORKER:
+		workerConfig := &porterv1.WorkerServiceConfig{}
+		err := helpers.UnmarshalContractObject(configBytes, workerConfig)
+		if err != nil {
+			return nil, fmt.Errorf("Error unmarshaling worker service config: %w", err)
+		}
+
+		validSevice.Config = &porterv1.Service_WorkerConfig{
+			WorkerConfig: workerConfig,
+		}
+	case porterv1.ServiceType_SERVICE_TYPE_JOB:
+		jobConfig := &porterv1.JobServiceConfig{}
+		err := helpers.UnmarshalContractObject(configBytes, jobConfig)
+		if err != nil {
+			return nil, fmt.Errorf("Error unmarshaling job service config: %w", err)
+		}
+
+		validSevice.Config = &porterv1.Service_JobConfig{
+			JobConfig: jobConfig,
+		}
+	}
+
+	return validSevice, nil
+}
+
+func appsFromApplicationGroup(porterYaml PorterStackYAML) ([]Application, error) {
+	ctx, span := telemetry.NewSpan(context.Background(), "extract-apps-from-porter-yaml")
+	defer span.End()
+
+	apps := make([]Application, 0)
+
+	if porterYaml.Applications == nil {
+		app, err := yamlToApplication(porterYaml)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error getting single app from porter yaml")
+		}
+
+		apps = append(apps, app)
+		return apps, nil
+	}
+
+	for name, app := range porterYaml.Applications {
+		if app.Services == nil {
+			return nil, telemetry.Error(ctx, span, nil, "no services defined for an app in porter yaml")
+		}
+
+		apps = append(apps, Application{
+			Name:     name,
+			Env:      app.Env,
+			Services: app.Services,
+			Build:    app.Build,
+			Release:  app.Release,
+		})
+	}
+
+	return apps, nil
+}

+ 100 - 0
api/server/handlers/porter_app/validate.go

@@ -0,0 +1,100 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"github.com/bufbuild/connect-go"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app/conversion"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type ValidatePorterAppRequest struct {
+	DeploymentTargetID uint   `json:"deployment_target_id"`
+	AppName            string `json:"app_name"`
+	PorterYAMLBase64   string `json:"porter_yaml"`
+	LatestCommit       string `json:"latest_commit"`
+}
+
+type ValidatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewValidatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ValidatePorterAppHandler {
+	return &ValidatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-validate-porter-app")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "project-id", Value: project.ID})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
+
+	// read the request body
+	request := &ValidatePorterAppRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error decoding porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// validate the porter yaml
+	apps, err := conversion.AppProtoFromYaml(porterYaml)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error extracting apps from porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	target, ok := apps[request.AppName]
+	if !ok {
+		err = telemetry.Error(ctx, span, err, "app name not found in porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// validate the app
+	validateRequest := connect.NewRequest(&porterv1.ValidatePorterAppRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: int64(request.DeploymentTargetID),
+		Application:        target,
+	})
+
+	validation, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateRequest)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error sending contract for update")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	c.WriteResult(w, r, validation.Msg)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -370,5 +370,34 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/validate -> porter_app.NewValidatePorterAppHandler
+	validatePorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/validate", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	validatePorterAppHandler := porter_app.NewValidatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: validatePorterAppEndpoint,
+		Handler:  validatePorterAppHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 28 - 0
internal/models/app_revision.go

@@ -0,0 +1,28 @@
+package models
+
+import (
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+type AppRevision struct {
+	gorm.Model
+
+	// ID is a UUID for the AppRevision
+	ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
+
+	// Base64Application is the PorterApp as json encoded in base64
+	Base64Application string `json:"base64_application"`
+
+	// Status is the status of the apply that happened for this revision.
+	Status string `json:"status"`
+
+	// DeploymentTargetID is the ID of the deployment target that the revision applies to.
+	DeploymentTargetID uuid.UUID `json:"deployment_target_id"`
+
+	// ProjectID is the ID of the project that the revision belongs to.
+	ProjectID int `json:"project_id"`
+
+	// PorterAppID is the ID of the PorterApp that the revision belongs to.
+	PorterAppID int `json:"porter_app_id"`
+}

+ 22 - 0
internal/models/deployment_target.go

@@ -0,0 +1,22 @@
+package models
+
+import (
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+type DeploymentTarget struct {
+	gorm.Model
+
+	// ID is a UUID for the Revision
+	ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` 
+
+	// ClusterID is the ID of the cluster that is being targeted.
+	ClusterID int `json:"cluster_id"`
+
+	// ProjectID is the ID of the project that the target belongs to.
+	ProjectID int `json:"project_id"`
+
+	// Selector is the identifier to target, such as a namespace or a label selector.
+	Selector string `json:"selector"`
+}

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -62,6 +62,8 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.AWSAssumeRoleChain{},
 		&models.PorterApp{},
 		&models.PorterAppEvent{},
+		&models.AppRevision{},
+		&models.DeploymentTarget{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},