pkg/firebase/apphostingschema/apphostingschema.go (218 lines of code) (raw):

// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package apphostingschema provides functionality around parsing and managing apphosting.yaml. package apphostingschema import ( "fmt" "log" "os" "path/filepath" "strings" "github.com/GoogleCloudPlatform/buildpacks/pkg/firebase/faherror" "gopkg.in/yaml.v2" ) var ( validAvailabilityValues = map[string]bool{"BUILD": true, "RUNTIME": true} reservedKeys = map[string]bool{ "PORT": true, "K_SERVICE": true, "K_REVISION": true, "K_CONFIGURATION": true, } reservedFirebaseKeyPrefix = "X_FIREBASE_" ) // AppHostingSchema is the struct representation of apphosting.yaml. type AppHostingSchema struct { RunConfig RunConfig `yaml:"runConfig,omitempty"` Env []EnvironmentVariable `yaml:"env,omitempty"` Scripts Scripts `yaml:"scripts,omitempty"` OutputFiles OutputFiles `yaml:"outputFiles,omitempty"` } // NetworkInterface is the struct representation of the passed network interface in VPC direct connect. type NetworkInterface struct { Network string `yaml:"network,omitempty"` Subnetwork string `yaml:"subnetwork,omitempty"` Tags []string `yaml:"tags,omitempty"` } // VpcAccess is the struct representation of the passed vpc access. type VpcAccess struct { Connector string `yaml:"connector,omitempty"` Egress string `yaml:"egress,omitempty"` NetworkInterfaces []NetworkInterface `yaml:"networkInterfaces,omitempty"` } // RunConfig is the struct representation of the passed run config. type RunConfig struct { // value types used must match server field types. pointers are used to capture unset vs zero-like values. CPU *float32 `yaml:"cpu"` MemoryMiB *int32 `yaml:"memoryMiB"` Concurrency *int32 `yaml:"concurrency"` MaxInstances *int32 `yaml:"maxInstances"` MinInstances *int32 `yaml:"minInstances"` VpcAccess *VpcAccess `yaml:"vpcAccess"` } // EnvironmentVariable is the struct representation of the passed environment variables. type EnvironmentVariable struct { Variable string `yaml:"variable"` Value string `yaml:"value,omitempty"` // Optional: Can be value xor secret Secret string `yaml:"secret,omitempty"` // Optional: Can be value xor secret Availability []string `yaml:"availability,omitempty"` } // Scripts is the struct representation of the scripts in apphosting.yaml. type Scripts struct { RunCommand string `yaml:"runCommand,omitempty"` BuildCommand string `yaml:"buildCommand,omitempty"` } // OutputFiles is the struct representation of the passed output files. type OutputFiles struct { ServerApp serverApp `yaml:"serverApp"` } // serverApp is the struct representation of the passed server app files. type serverApp struct { Include []string `yaml:"include"` } // UnmarshalYAML provides custom validation logic to validate EnvironmentVariable func (ev *EnvironmentVariable) UnmarshalYAML(unmarshal func(any) error) error { type plain EnvironmentVariable // Define an alias if err := unmarshal((*plain)(ev)); err != nil { return err } if ev.Value != "" && ev.Secret != "" { return fmt.Errorf("both 'value' and 'secret' fields cannot be present") } if ev.Value == "" && ev.Secret == "" { return fmt.Errorf("either 'value' or 'secret' field is required") } for _, val := range ev.Availability { if !validAvailabilityValues[val] { return fmt.Errorf("invalid value in 'availability': %s", val) } } return nil } // UnmarshalYAML provides custom validation logic to validate RunConfig func (rc *RunConfig) UnmarshalYAML(unmarshal func(any) error) error { type plain RunConfig // Define an alias if err := unmarshal((*plain)(rc)); err != nil { return err } // Validation for 'CPU' if rc.CPU != nil && !(1 <= *rc.CPU && *rc.CPU <= 8) { return fmt.Errorf("runConfig.cpu field is not in valid range of [1, 8]") } // Validation for 'MemoryMiB' if rc.MemoryMiB != nil && !(512 <= *rc.MemoryMiB && *rc.MemoryMiB <= 32768) { return fmt.Errorf("runConfig.memory field is not in valid range of [512, 32768]") } // Validation for 'Concurrency' if rc.Concurrency != nil && !(1 <= *rc.Concurrency && *rc.Concurrency <= 1000) { return fmt.Errorf("runConfig.concurrency field is not in valid range of [1, 1000]") } // Validation for 'MaxInstances' if rc.MaxInstances != nil && !(1 <= *rc.MaxInstances && *rc.MaxInstances <= 100) { return fmt.Errorf("runConfig.maxInstances field is not in valid range of [1, 100]") } // Validation for 'minInstances' if rc.MinInstances != nil && !(0 <= *rc.MinInstances && *rc.MinInstances <= 100) { return fmt.Errorf("runConfig.minInstances field is not in valid range of [1, 100]") } if err := ValidateVpcAccess(rc.VpcAccess); err != nil { return err } return nil } // ReadAndValidateFromFile converts the provided file into an AppHostingSchema. // Returns an empty AppHostingSchema{} if the file does not exist. func ReadAndValidateFromFile(filePath string) (AppHostingSchema, error) { var a AppHostingSchema apphostingBuffer, err := os.ReadFile(filePath) if os.IsNotExist(err) { return a, nil } else if err != nil { return a, fmt.Errorf("reading apphosting config at %v: %w", filePath, err) } if err = yaml.Unmarshal(apphostingBuffer, &a); err != nil { return a, faherror.InvalidAppHostingYamlError(filePath, err) } return a, nil } func isReservedKey(envKey string) bool { if _, ok := reservedKeys[envKey]; ok { return true } else if strings.HasPrefix(envKey, reservedFirebaseKeyPrefix) { return true } return false } func santizeEnv(env []EnvironmentVariable) []EnvironmentVariable { if env == nil { return nil } var sanitizedSchemaEnv []EnvironmentVariable for _, ev := range env { if !isReservedKey(ev.Variable) { if ev.Availability == nil { log.Printf("%s has no availability specified, applying the default of 'BUILD' and 'RUNTIME'", ev.Variable) ev.Availability = []string{"BUILD", "RUNTIME"} } sanitizedSchemaEnv = append(sanitizedSchemaEnv, ev) } else { log.Printf("WARNING: %s is a reserved key, removing it from the final environment variables", ev.Variable) } } return sanitizedSchemaEnv } // Sanitize strips reserved environment variables from the environment variable // list. func Sanitize(schema *AppHostingSchema) { schema.Env = santizeEnv(schema.Env) } // Merge app hosting schemas with priority given to any environment specific overrides func mergeAppHostingSchemas(appHostingSchema *AppHostingSchema, envSpecificSchema *AppHostingSchema) { // Merge RunConfig if envSpecificSchema.RunConfig.CPU != nil { appHostingSchema.RunConfig.CPU = envSpecificSchema.RunConfig.CPU } if envSpecificSchema.RunConfig.MemoryMiB != nil { appHostingSchema.RunConfig.MemoryMiB = envSpecificSchema.RunConfig.MemoryMiB } if envSpecificSchema.RunConfig.Concurrency != nil { appHostingSchema.RunConfig.Concurrency = envSpecificSchema.RunConfig.Concurrency } if envSpecificSchema.RunConfig.MaxInstances != nil { appHostingSchema.RunConfig.MaxInstances = envSpecificSchema.RunConfig.MaxInstances } if envSpecificSchema.RunConfig.MinInstances != nil { appHostingSchema.RunConfig.MinInstances = envSpecificSchema.RunConfig.MinInstances } appHostingSchema.RunConfig.VpcAccess = MergeVpcAccess(appHostingSchema.RunConfig.VpcAccess, envSpecificSchema.RunConfig.VpcAccess) // Merge Environment Variables appHostingSchema.Env = MergeEnvVars(appHostingSchema.Env, envSpecificSchema.Env) } // MergeEnvVars merges the environment variables from the original list with the override list. // If there is a conflict between the environment variables, use the value/secret from the override list. func MergeEnvVars(original, override []EnvironmentVariable) []EnvironmentVariable { merged := override varByName := make(map[string]EnvironmentVariable) for _, ev := range override { varByName[ev.Variable] = ev } for _, ev := range original { if _, found := varByName[ev.Variable]; !found { merged = append(merged, ev) } else { log.Printf("Skipping environment variable %v from original list since it is already defined in the override list\n", ev.Variable) } } return merged } // MergeWithEnvironmentSpecificYAML merges the environment specific apphosting.<environmentName>.yaml with the base apphosting schema found in apphosting.yaml func MergeWithEnvironmentSpecificYAML(appHostingSchema *AppHostingSchema, appHostingYAMLPath string, environmentName string) error { if environmentName == "" { return nil } envSpecificYAMLPath := filepath.Join(filepath.Dir(appHostingYAMLPath), fmt.Sprintf("apphosting.%v.yaml", environmentName)) envSpecificSchema, err := ReadAndValidateFromFile(envSpecificYAMLPath) if err != nil { return fmt.Errorf("reading environment specific apphosting schema: %w", err) } mergeAppHostingSchemas(appHostingSchema, &envSpecificSchema) return nil } // IsKeyUserDefined determines whether the provided KEY environment variable is already user defined. func IsKeyUserDefined(schema *AppHostingSchema, key string) bool { for _, e := range schema.Env { if e.Variable == key { return true } } return false } // WriteToFile writes the given app hosting schema to the specified path. func (schema *AppHostingSchema) WriteToFile(outputFilePath string) error { fileData, err := yaml.Marshal(schema) if err != nil { return fmt.Errorf("converting struct to YAML: %w", err) } log.Printf("Final app hosting schema:\n%v\n", string(fileData)) if err := os.MkdirAll(filepath.Dir(outputFilePath), os.ModeDir); err != nil { return fmt.Errorf("creating parent directory %q: %w", outputFilePath, err) } file, err := os.Create(outputFilePath) if err != nil { return fmt.Errorf("creating app hosting schema file: %w", err) } defer file.Close() if _, err := file.Write(fileData); err != nil { return fmt.Errorf("writing app hosting schema data to file: %w", err) } return nil }