Files
dokku/plugins/ps/functions.go
Jose Diaz-Gonzalez 1eb41f32dc feat: only redeploy formations that have had their values changed
Rather than redeploy every formation, only deploy formations that have had their values changed from the defaults.

This still doesn't handle the case where we touch existing processes when scaling _up_, but should decrease the number of services touched otherwise during scaling.

Also note that there are no tests for this new behavior yet (tests incoming).

Refs #7396
2025-06-23 04:42:09 -04:00

408 lines
10 KiB
Go

package ps
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/dokku/dokku/plugins/common"
dockeroptions "github.com/dokku/dokku/plugins/docker-options"
)
func canScaleApp(appName string) bool {
canScale := common.PropertyGetDefault("ps", appName, "can-scale", "true")
return common.ToBool(canScale)
}
func getProcfileCommand(procfilePath string, processType string, port int) (string, error) {
if !common.FileExists(procfilePath) {
return "", errors.New("No procfile found")
}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "procfile-util",
Args: []string{"show", "--procfile", procfilePath, "--process-type", processType, "--default-port", strconv.Itoa(port)},
})
if err != nil {
return "", fmt.Errorf("Error running procfile-util: %s", err)
}
if result.ExitCode != 0 {
return "", fmt.Errorf("Error running procfile-util: %s", result.StderrContents())
}
return result.StdoutContents(), nil
}
func getProcfilePath(appName string) string {
directory := common.GetAppDataDirectory("ps", appName)
return filepath.Join(directory, "Procfile")
}
func getProcessSpecificProcfilePath(appName string) string {
existingProcfile := getProcfilePath(appName)
processSpecificProcfile := fmt.Sprintf("%s.%s", existingProcfile, os.Getenv("DOKKU_PID"))
if common.FileExists(processSpecificProcfile) {
return processSpecificProcfile
}
return existingProcfile
}
func getRestartPolicy(appName string) (string, error) {
options, err := dockeroptions.GetDockerOptionsForPhase(appName, "deploy")
if err != nil {
return "", err
}
for _, option := range options {
if strings.HasPrefix(option, "--restart=") {
return strings.TrimPrefix(option, "--restart="), nil
}
}
return "", nil
}
func getProcessCount(appName string) (int, error) {
scheduler := common.GetAppScheduler(appName)
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "scheduler-app-status",
Args: []string{scheduler, appName},
})
count := strings.Split(results.StdoutContents(), " ")[0]
return strconv.Atoi(count)
}
func getRunningState(appName string) string {
scheduler := common.GetAppScheduler(appName)
results, _ := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "scheduler-app-status",
Args: []string{scheduler, appName},
})
return strings.Split(results.StdoutContents(), " ")[1]
}
func hasProcfile(appName string) bool {
procfilePath := getProcfilePath(appName)
if common.FileExists(fmt.Sprintf("%s.%s.missing", procfilePath, os.Getenv("DOKKU_PID"))) {
return false
}
if common.FileExists(fmt.Sprintf("%s.%s", procfilePath, os.Getenv("DOKKU_PID"))) {
return true
}
return common.FileExists(procfilePath)
}
func isValidRestartPolicy(policy string) bool {
if policy == "" {
return false
}
validRestartPolicies := map[string]bool{
"no": true,
"always": true,
"unless-stopped": true,
"on-failure": true,
}
if _, ok := validRestartPolicies[policy]; ok {
return true
}
return strings.HasPrefix(policy, "on-failure:")
}
func parseProcessTuples(processTuples []string) (FormationSlice, error) {
formations := FormationSlice{}
foundFormations := map[string]bool{}
for _, processTuple := range processTuples {
s := strings.SplitN(processTuple, "=", 2)
if len(s) == 1 {
return formations, fmt.Errorf("Missing count for process type %s", processTuple)
}
processType := strings.TrimSpace(s[0])
quantity, err := strconv.Atoi(strings.TrimSpace(s[1]))
if err != nil {
return formations, fmt.Errorf("Invalid count for process type %s", s[0])
}
if foundFormations[processType] {
continue
}
foundFormations[processType] = true
formations = append(formations, &Formation{
ProcessType: processType,
Quantity: quantity,
})
}
return formations, nil
}
func processesInProcfile(procfilePath string) (map[string]bool, error) {
processes := map[string]bool{}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: "procfile-util",
Args: []string{"list", "--procfile", procfilePath},
})
if err != nil {
return processes, fmt.Errorf("Error listing processes: %s", err)
}
if result.ExitCode != 0 {
return processes, fmt.Errorf("Error listing processes: %s", result.StderrContents())
}
for _, s := range strings.Split(result.StdoutContents(), "\n") {
processes[s] = true
}
return processes, nil
}
func getFormations(appName string) (FormationSlice, error) {
formations := FormationSlice{}
processTuples, err := common.PropertyListGet("ps", appName, "scale")
if err != nil {
return formations, err
}
oldProcessTuples, err := common.PropertyListGet("ps", appName, "scale.old")
if err != nil {
return formations, err
}
formations, err = parseProcessTuples(processTuples)
if err != nil {
return formations, err
}
oldFormations, err := parseProcessTuples(oldProcessTuples)
if err != nil {
return formations, err
}
foundProcessTypes := map[string]bool{}
for _, formation := range formations {
foundProcessTypes[formation.ProcessType] = true
}
for _, formation := range oldFormations {
if foundProcessTypes[formation.ProcessType] {
continue
}
foundProcessTypes[formation.ProcessType] = true
formations = append(formations, formation)
}
sort.Sort(formations)
return formations, nil
}
func restorePrep() error {
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "proxy-clear-config",
Args: []string{"--all"},
StreamStdio: true,
})
if err != nil {
return fmt.Errorf("Error clearing proxy config: %s", err)
}
return nil
}
func scaleReport(appName string) error {
formations, err := getFormations(appName)
if err != nil {
return err
}
common.LogInfo1Quiet(fmt.Sprintf("Scaling for %s", appName))
content := []string{}
if os.Getenv("DOKKU_QUIET_OUTPUT") == "" {
content = append(content, "proctype=qty", "--------=---")
}
for _, formation := range formations {
content = append(content, fmt.Sprintf("%s=%d", formation.ProcessType, formation.Quantity))
}
for _, line := range content {
s := strings.Split(line, "=")
common.Log(fmt.Sprintf("%s %s", common.RightPad(fmt.Sprintf("%s:", s[0]), 5, " "), s[1]))
}
return nil
}
// scaleSetInput is the input for the scaleSet function
type scaleSetInput struct {
// appName is the name of the app to scale
appName string
// skipDeploy is a flag to skip the deploy phase
skipDeploy bool
// clearExisting is a flag to clear the existing scale
clearExisting bool
// processTuples is a list of process tuples to scale
processTuples []string
// deployOnlyChanged is a flag to deploy only the changed formations
deployOnlyChanged bool
}
func scaleSet(input scaleSetInput) error {
existingFormations, err := getFormations(input.appName)
if err != nil {
return err
}
formations, err := parseProcessTuples(input.processTuples)
if err != nil {
return err
}
if err := updateScale(input.appName, input.clearExisting, formations); err != nil {
return err
}
if input.skipDeploy {
return nil
}
if !common.IsDeployed(input.appName) {
return nil
}
imageTag, err := common.GetRunningImageTag(input.appName, "")
if err != nil {
return err
}
changedFormations := FormationSlice{}
if input.deployOnlyChanged {
for _, formation := range formations {
isChanged := true
for _, existingFormation := range existingFormations {
if existingFormation.ProcessType == formation.ProcessType {
if existingFormation.Quantity == formation.Quantity {
isChanged = false
break
}
}
}
if isChanged {
changedFormations = append(changedFormations, formation)
}
}
} else {
changedFormations = formations
}
for _, formation := range changedFormations {
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "deploy",
Args: []string{input.appName, imageTag, formation.ProcessType},
StreamStdio: true,
})
if err != nil {
return err
}
}
return nil
}
func updateScale(appName string, clearExisting bool, formationUpdates FormationSlice) error {
formations := FormationSlice{}
if !clearExisting {
processTuples, err := common.PropertyListGet("ps", appName, "scale")
if err != nil {
return err
}
formations, err = parseProcessTuples(processTuples)
if err != nil {
return err
}
}
validProcessTypes := make(map[string]bool)
if hasProcfile(appName) {
var err error
validProcessTypes, err = processesInProcfile(getProcessSpecificProcfilePath(appName))
if err != nil {
return err
}
}
foundProcessTypes := map[string]bool{}
updatedFormation := FormationSlice{}
for _, formation := range formationUpdates {
if hasProcfile(appName) && !validProcessTypes[formation.ProcessType] && formation.Quantity != 0 {
return fmt.Errorf("%s is not a valid process name to scale up", formation.ProcessType)
}
foundProcessTypes[formation.ProcessType] = true
updatedFormation = append(updatedFormation, &Formation{
ProcessType: formation.ProcessType,
Quantity: formation.Quantity,
})
}
for _, formation := range formations {
if foundProcessTypes[formation.ProcessType] {
continue
}
foundProcessTypes[formation.ProcessType] = true
updatedFormation = append(updatedFormation, &Formation{
ProcessType: formation.ProcessType,
Quantity: formation.Quantity,
})
}
for processType := range validProcessTypes {
if foundProcessTypes[processType] {
continue
}
updatedFormation = append(updatedFormation, &Formation{
ProcessType: processType,
Quantity: 0,
})
}
values := []string{}
oldValues := []string{}
for _, formation := range updatedFormation {
if !validProcessTypes[formation.ProcessType] && formation.Quantity == 0 {
oldValues = append(oldValues, fmt.Sprintf("%s=%d", formation.ProcessType, formation.Quantity))
continue
}
values = append(values, fmt.Sprintf("%s=%d", formation.ProcessType, formation.Quantity))
}
if err := common.PropertyListWrite("ps", appName, "scale.old", oldValues); err != nil {
return err
}
return common.PropertyListWrite("ps", appName, "scale", values)
}