github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/service/systemd/conf.go (about)

     1  // Copyright 2015 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package systemd
     5  
     6  import (
     7  	"bytes"
     8  	"fmt"
     9  	"io"
    10  	"sort"
    11  	"strconv"
    12  	"strings"
    13  
    14  	"github.com/coreos/go-systemd/v22/unit"
    15  	"github.com/juju/errors"
    16  	"github.com/juju/utils/v3/shell"
    17  
    18  	"github.com/juju/juju/core/paths"
    19  	"github.com/juju/juju/service/common"
    20  )
    21  
    22  var limitMap = map[string]string{
    23  	"as":         "LimitAS",
    24  	"core":       "LimitCORE",
    25  	"cpu":        "LimitCPU",
    26  	"data":       "LimitDATA",
    27  	"fsize":      "LimitFSIZE",
    28  	"memlock":    "LimitMEMLOCK",
    29  	"msgqueue":   "LimitMSGQUEUE",
    30  	"nice":       "LimitNICE",
    31  	"nofile":     "LimitNOFILE",
    32  	"nproc":      "LimitNPROC",
    33  	"rss":        "LimitRSS",
    34  	"rtprio":     "LimitRTPRIO",
    35  	"sigpending": "LimitSIGPENDING",
    36  	"stack":      "LimitSTACK",
    37  }
    38  
    39  // TODO(ericsnow) Move normalize to common.Conf.Normalize.
    40  
    41  type confRenderer interface {
    42  	shell.Renderer
    43  	shell.ScriptRenderer
    44  }
    45  
    46  // normalize adjusts the conf to more standardized content and
    47  // returns a new Conf with that updated content. It also returns the
    48  // content of any script file that should accompany the conf.
    49  func normalize(name string, conf common.Conf, scriptPath string, renderer confRenderer) (common.Conf, []byte) {
    50  	var data []byte
    51  
    52  	var cmds []string
    53  	if conf.Logfile != "" {
    54  		filename := conf.Logfile
    55  		cmds = append(cmds, "# Set up logging.")
    56  		cmds = append(cmds, renderer.Touch(filename, nil)...)
    57  		user, group := paths.SyslogUserGroup()
    58  		cmds = append(cmds, renderer.Chown(filename, user, group)...)
    59  		cmds = append(cmds, renderer.Chmod(filename, paths.LogfilePermission)...)
    60  		cmds = append(cmds, renderer.RedirectOutput(filename)...)
    61  		cmds = append(cmds, renderer.RedirectFD("out", "err")...)
    62  		cmds = append(cmds,
    63  			"",
    64  			"# Run the script.",
    65  		)
    66  		// We leave conf.Logfile alone (it will be ignored during validation).
    67  	}
    68  	cmds = append(cmds, conf.ExecStart)
    69  
    70  	if conf.ExtraScript != "" {
    71  		cmds = append([]string{conf.ExtraScript}, cmds...)
    72  		conf.ExtraScript = ""
    73  	}
    74  	if !isSimpleCommand(strings.Join(cmds, "\n")) {
    75  		data = renderer.RenderScript(cmds)
    76  		conf.ExecStart = scriptPath
    77  	}
    78  
    79  	if len(conf.Env) == 0 {
    80  		conf.Env = nil
    81  	}
    82  
    83  	if len(conf.Limit) == 0 {
    84  		conf.Limit = nil
    85  	}
    86  
    87  	if conf.Transient {
    88  		// TODO(ericsnow) Handle Transient via systemd-run command?
    89  		conf.ExecStopPost = commands{}.disable(name)
    90  	}
    91  
    92  	return conf, data
    93  }
    94  
    95  func isSimpleCommand(cmd string) bool {
    96  	if strings.ContainsAny(cmd, "\n;|><&") {
    97  		return false
    98  	}
    99  
   100  	return true
   101  }
   102  
   103  func validate(name string, conf common.Conf, renderer shell.Renderer) error {
   104  	if name == "" {
   105  		return errors.NotValidf("missing service name")
   106  	}
   107  
   108  	if err := conf.Validate(renderer); err != nil {
   109  		return errors.Trace(err)
   110  	}
   111  
   112  	if conf.ExtraScript != "" {
   113  		return errors.NotValidf("unexpected ExtraScript")
   114  	}
   115  
   116  	// We ignore Desc and Logfile.
   117  
   118  	for k := range conf.Limit {
   119  		if _, ok := limitMap[k]; !ok {
   120  			return errors.NotValidf("conf.Limit key %q", k)
   121  		}
   122  	}
   123  
   124  	return nil
   125  }
   126  
   127  // serialize returns the data that should be written to disk for the
   128  // provided Conf, rendered in the systemd unit file format.
   129  func serialize(name string, conf common.Conf, renderer shell.Renderer) ([]byte, error) {
   130  	if err := validate(name, conf, renderer); err != nil {
   131  		return nil, errors.Trace(err)
   132  	}
   133  
   134  	var unitOptions []*unit.UnitOption
   135  	unitOptions = append(unitOptions, serializeUnit(conf)...)
   136  	unitOptions = append(unitOptions, serializeService(conf)...)
   137  	unitOptions = append(unitOptions, serializeInstall(conf)...)
   138  	// Don't use unit.Serialize because it has map ordering issues.
   139  	// Serialize copied locally, and outputs sections in alphabetical order.
   140  	data, err := io.ReadAll(UnitSerialize(unitOptions))
   141  	return data, errors.Trace(err)
   142  }
   143  
   144  func serializeUnit(conf common.Conf) []*unit.UnitOption {
   145  	var unitOptions []*unit.UnitOption
   146  
   147  	if conf.Desc != "" {
   148  		unitOptions = append(unitOptions, &unit.UnitOption{
   149  			Section: "Unit",
   150  			Name:    "Description",
   151  			Value:   conf.Desc,
   152  		})
   153  	}
   154  
   155  	after := []string{
   156  		"syslog.target",
   157  		"network.target",
   158  		"systemd-user-sessions.service",
   159  	}
   160  	for _, name := range after {
   161  		unitOptions = append(unitOptions, &unit.UnitOption{
   162  			Section: "Unit",
   163  			Name:    "After",
   164  			Value:   name,
   165  		})
   166  	}
   167  
   168  	if conf.AfterStopped != "" {
   169  		unitOptions = append(unitOptions, &unit.UnitOption{
   170  			Section: "Unit",
   171  			Name:    "After",
   172  			Value:   conf.AfterStopped,
   173  		})
   174  	}
   175  
   176  	return unitOptions
   177  }
   178  
   179  func iterSortedKeys(in map[string]string) []string {
   180  	out := make([]string, 0, len(in))
   181  	for k := range in {
   182  		out = append(out, k)
   183  	}
   184  	sort.Strings(out)
   185  	return out
   186  }
   187  
   188  // ServiceLimits converts the limits in conf to systemd unit options.
   189  func ServiceLimits(conf common.Conf) []*unit.UnitOption {
   190  	var unitOptions []*unit.UnitOption
   191  	for _, k := range iterSortedKeys(conf.Limit) {
   192  		v := conf.Limit[k]
   193  		if v == "unlimited" {
   194  			// In ulimit you pass 'unlimited', but this maps to "infinity" in systemd
   195  			v = "infinity"
   196  		}
   197  		unitOptions = append(unitOptions, &unit.UnitOption{
   198  			Section: "Service",
   199  			Name:    limitMap[k],
   200  			Value:   v,
   201  		})
   202  	}
   203  	return unitOptions
   204  }
   205  
   206  func serializeService(conf common.Conf) []*unit.UnitOption {
   207  	var unitOptions []*unit.UnitOption
   208  
   209  	// TODO(ericsnow) Support "Type" (e.g. "forking")? For now we just
   210  	// use the default, "simple".
   211  
   212  	for k, v := range conf.Env {
   213  		unitOptions = append(unitOptions, &unit.UnitOption{
   214  			Section: "Service",
   215  			Name:    "Environment",
   216  			Value:   fmt.Sprintf(`"%s=%s"`, k, v),
   217  		})
   218  	}
   219  	unitOptions = append(unitOptions, ServiceLimits(conf)...)
   220  
   221  	if conf.ExecStart != "" {
   222  		unitOptions = append(unitOptions, &unit.UnitOption{
   223  			Section: "Service",
   224  			Name:    "ExecStart",
   225  			Value:   conf.ExecStart,
   226  		})
   227  	}
   228  
   229  	// TODO(ericsnow) This should key off Conf.Restart, once added.
   230  	if !conf.Transient {
   231  		unitOptions = append(unitOptions, &unit.UnitOption{
   232  			Section: "Service",
   233  			Name:    "Restart",
   234  			Value:   "on-failure",
   235  		})
   236  	}
   237  
   238  	if conf.Timeout > 0 {
   239  		unitOptions = append(unitOptions, &unit.UnitOption{
   240  			Section: "Service",
   241  			Name:    "TimeoutSec",
   242  			Value:   strconv.Itoa(conf.Timeout),
   243  		})
   244  	}
   245  
   246  	if conf.ExecStopPost != "" {
   247  		unitOptions = append(unitOptions, &unit.UnitOption{
   248  			Section: "Service",
   249  			Name:    "ExecStopPost",
   250  			Value:   conf.ExecStopPost,
   251  		})
   252  	}
   253  
   254  	return unitOptions
   255  }
   256  
   257  func serializeInstall(conf common.Conf) []*unit.UnitOption {
   258  	var unitOptions []*unit.UnitOption
   259  
   260  	unitOptions = append(unitOptions, &unit.UnitOption{
   261  		Section: "Install",
   262  		Name:    "WantedBy",
   263  		Value:   "multi-user.target",
   264  	})
   265  
   266  	return unitOptions
   267  }
   268  
   269  // deserialize parses the provided data (in the systemd unit file
   270  // format) and populates a new Conf with the result.
   271  func deserialize(data []byte, renderer shell.Renderer) (common.Conf, error) {
   272  	opts, err := unit.Deserialize(bytes.NewBuffer(data))
   273  	if err != nil {
   274  		return common.Conf{}, errors.Trace(err)
   275  	}
   276  	return deserializeOptions(opts, renderer)
   277  }
   278  
   279  func deserializeOptions(opts []*unit.UnitOption, renderer shell.Renderer) (common.Conf, error) {
   280  	var conf common.Conf
   281  
   282  	for _, uo := range opts {
   283  		switch uo.Section {
   284  		case "Unit":
   285  			switch uo.Name {
   286  			case "Description":
   287  				conf.Desc = uo.Value
   288  			case "After":
   289  				// Do nothing until we support it in common.Conf.
   290  			default:
   291  				return conf, errors.NotSupportedf("Unit directive %q", uo.Name)
   292  			}
   293  		case "Service":
   294  			switch {
   295  			case uo.Name == "ExecStart":
   296  				conf.ExecStart = uo.Value
   297  			case uo.Name == "Environment":
   298  				if conf.Env == nil {
   299  					conf.Env = make(map[string]string)
   300  				}
   301  				var value = uo.Value
   302  				if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) {
   303  					value = value[1 : len(value)-1]
   304  				}
   305  				parts := strings.SplitN(value, "=", 2)
   306  				if len(parts) != 2 {
   307  					return conf, errors.NotValidf("service environment value %q", uo.Value)
   308  				}
   309  				conf.Env[parts[0]] = parts[1]
   310  			case strings.HasPrefix(uo.Name, "Limit"):
   311  				if conf.Limit == nil {
   312  					conf.Limit = make(map[string]string)
   313  				}
   314  				for k, v := range limitMap {
   315  					if v == uo.Name {
   316  						conf.Limit[k] = uo.Value
   317  						break
   318  					}
   319  				}
   320  			case uo.Name == "TimeoutSec":
   321  				timeout, err := strconv.Atoi(uo.Value)
   322  				if err != nil {
   323  					return conf, errors.Trace(err)
   324  				}
   325  				conf.Timeout = timeout
   326  			case uo.Name == "Type":
   327  				// Do nothing until we support it in common.Conf.
   328  			case uo.Name == "RemainAfterExit":
   329  				// Do nothing until we support it in common.Conf.
   330  			case uo.Name == "Restart":
   331  				// Do nothing until we support it in common.Conf.
   332  			default:
   333  				return conf, errors.NotSupportedf("Service directive %q", uo.Name)
   334  			}
   335  		case "Install":
   336  			switch uo.Name {
   337  			case "WantedBy":
   338  				if uo.Value != "multi-user.target" {
   339  					return conf, errors.NotValidf("unit target %q", uo.Value)
   340  				}
   341  			default:
   342  				return conf, errors.NotSupportedf("Install directive %q", uo.Name)
   343  			}
   344  		default:
   345  			return conf, errors.NotSupportedf("section %q", uo.Name)
   346  		}
   347  	}
   348  
   349  	err := validate("<>", conf, renderer)
   350  	return conf, errors.Trace(err)
   351  }