package config import ( "bytes" "context" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" corev1 "k8s.io/api/core/v1" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "edge-infra.dev/pkg/sds/display/constants" ) const ( xServerConfig = "xserver-config" GlobalOpenboxConfig = "global-openbox-config" xorgConfKey = "xorg.conf" openboxConfKey = "openbox.conf" cursorEnabledKey = "cursor-enabled" displayctlEnabledKey = "displayctl-enabled" xorgConfPath = "/etc/X11/xorg.conf" mode = fs.FileMode(0644) ) var ( errXServerConfigMapCannotBeNil = errors.New("xserver-config ConfigMap cannot be nil") errXServerConfigMapNameHasFromFormat = errors.New("xserver-config ConfigMap name should have format 'xserver-config-HOSTNAME'") errFlagShouldBeTrueFalse = errors.New("flag value should be 'true' or 'false'") errOpenboxConfCannotBeEmpty = errors.New("openbox config cannot be empty") openboxConfPath = filepath.Join(os.Getenv("HOME"), ".config/openbox/rc.xml") ) // Config allows configuration of the X server. type Config struct { // Hostname for the node the X server is running on. Hostname string xorgConf *string openboxConf *string cursorEnabled *bool displayctlEnabled *bool } func New(hostname string) *Config { return &Config{ Hostname: hostname, } } // Whether a and b produce the same X server configuration. // // Ignores displayctl-enabled as this has no effect on the // X server configuration. func Equal(a, b *Config) bool { if a == nil || b == nil { return a == b } if a.XorgConf() != b.XorgConf() { return false } if a.OpenboxConf() != b.OpenboxConf() { return false } return a.CursorEnabled() == b.CursorEnabled() } // Returns a deep-copy of the X server config. func (config *Config) DeepCopy() *Config { if config == nil { return nil } dst := new(Config) config.DeepCopyInto(dst) return dst } // Deep-copies the X server config into the destination. func (config *Config) DeepCopyInto(dst *Config) { // the logic here is the same as controller-gen's zz_generated.deepcopy.go *dst = *config if config.xorgConf != nil { dst, config := &dst.xorgConf, &config.xorgConf *dst = new(string) **dst = **config } if config.openboxConf != nil { dst, config := &dst.openboxConf, &config.openboxConf *dst = new(string) **dst = **config } if config.cursorEnabled != nil { dst, config := &dst.cursorEnabled, &config.cursorEnabled *dst = new(bool) **dst = **config } if config.displayctlEnabled != nil { dst, config := &dst.displayctlEnabled, &config.displayctlEnabled *dst = new(bool) **dst = **config } } // xorg.conf configuration provided to the X server. // // Defaults to empty if if has not been configured. func (config *Config) XorgConf() string { if config.xorgConf == nil { return "" } return *config.xorgConf } func (config *Config) SetXorgConf(xorgConf string) { config.xorgConf = &xorgConf } // Updates /etc/X11/xorg.conf with the configured Xorg config. func (config *Config) UpdateXorgConf(context.Context) error { return safeUpdateFile(xorgConfPath, config.XorgConf(), mode) } // openbox.conf configuration provided to the X server. // // Defaults to empty if if has not been configured. func (config *Config) OpenboxConf() string { if config.openboxConf == nil { return "" } return *config.openboxConf } func (config *Config) SetOpenboxConf(openboxConf string) { config.openboxConf = &openboxConf } // Updates $HOME/.config/openbox/rc.xml with the configured Openbox config. func (config *Config) UpdateOpenboxConf(context.Context) error { return safeUpdateFile(openboxConfPath, config.OpenboxConf(), mode) } // Whether the cursor is enabled. // // Defaults to cursor disabled if it has not been configured. func (config *Config) CursorEnabled() bool { if config.cursorEnabled == nil { return false } return *config.cursorEnabled } func (config *Config) SetCursorEnabled(cursorEnabled bool) { config.cursorEnabled = &cursorEnabled } // Whether displayctl is enabled. // // When not configured, displayctl will only be enabled when XorgConf has // not been defined. func (config *Config) DisplayctlEnabled() bool { if config.displayctlEnabled != nil { return *config.displayctlEnabled } return config.xorgConf == nil } func (config *Config) SetDisplayctlEnabled(displayctlEnabled bool) { config.displayctlEnabled = &displayctlEnabled } // The X server config in ConfigMap form. // // If config fields are nil, they will be omitted from the ConfigMap keys. func (config *Config) ToConfigMap() *corev1.ConfigMap { configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Namespace: constants.Namespace, Name: config.ConfigMapName(), }, Data: map[string]string{}, } if config.xorgConf != nil { configMap.Data[xorgConfKey] = *config.xorgConf } if config.openboxConf != nil { configMap.Data[openboxConfKey] = *config.openboxConf } if config.cursorEnabled != nil { configMap.Data[cursorEnabledKey] = strconv.FormatBool(*config.cursorEnabled) } if config.displayctlEnabled != nil { configMap.Data[displayctlEnabledKey] = strconv.FormatBool(*config.displayctlEnabled) } return configMap } // The name of the X server config's ConfigMap, i.e. "xserver-config-HOSTNAME". func (config *Config) ConfigMapName() string { return ConfigMapNameFromHostname(config.Hostname) } // Updates the X server config's ConfigMap in the cluster using the k8s // client, creating it if it does not exist. func (config *Config) UpdateConfigMap(ctx context.Context, c client.Client) error { configMap := config.ToConfigMap() currentConfigMap, err := ConfigMapFromClient(ctx, config.Hostname, c) if kerrors.IsNotFound(err) { return c.Create(ctx, configMap) } else if err != nil { return err } return c.Patch(ctx, configMap, client.MergeFrom(currentConfigMap)) } // Creates an X server config from a ConfigMap. // // If config keys are missing they will be set to nil. func FromConfigMap(configMap *corev1.ConfigMap) (*Config, error) { if configMap == nil { return nil, errXServerConfigMapCannotBeNil } config := &Config{} hostname, err := HostnameFromConfigMapName(configMap.GetName()) if err != nil { return nil, err } config.Hostname = hostname if configMap.Data == nil { return config, nil } if xorgConf, ok := configMap.Data[xorgConfKey]; ok { config.xorgConf = &xorgConf } if openboxConf, ok := configMap.Data[openboxConfKey]; ok { config.openboxConf = &openboxConf } if cursorEnabled, ok := configMap.Data[cursorEnabledKey]; ok { enabled, err := strconv.ParseBool(cursorEnabled) if err != nil { return nil, fmt.Errorf("%s %w, got: '%s'", cursorEnabledKey, errFlagShouldBeTrueFalse, cursorEnabled) } config.cursorEnabled = &enabled } if dispalyctlEnabled, ok := configMap.Data[displayctlEnabledKey]; ok { enabled, err := strconv.ParseBool(dispalyctlEnabled) if err != nil { return nil, fmt.Errorf("%s %w, got: '%s'", displayctlEnabledKey, errFlagShouldBeTrueFalse, dispalyctlEnabled) } config.displayctlEnabled = &enabled } return config, nil } // Fetches the host's X server ConfigMap with the K8s client and // returns the config it represents. func FromClient(ctx context.Context, hostname string, c client.Client) (*Config, error) { configMap, err := ConfigMapFromClient(ctx, hostname, c) if err != nil { return nil, fmt.Errorf("cannot get xserver-config ConfigMap for %s: %w", hostname, err) } config, err := FromConfigMap(configMap) if err != nil { return nil, fmt.Errorf("cannot create config from %s ConfigMap: %w", configMap.GetName(), err) } return config, nil } // Fetches the host's X server ConfigMap with the K8s client. // // nolint: revive func ConfigMapFromClient(ctx context.Context, hostname string, c client.Client) (*corev1.ConfigMap, error) { key := client.ObjectKey{ Namespace: constants.Namespace, Name: ConfigMapNameFromHostname(hostname), } configMap := &corev1.ConfigMap{} if err := c.Get(ctx, key, configMap); err != nil { return nil, err } return configMap, nil } // Fetches the global Openbox config from the global-openbox-config ConfigMap. func GlobalOpenboxConfFromClient(ctx context.Context, c client.Client) (string, error) { key := client.ObjectKey{ Namespace: constants.Namespace, Name: GlobalOpenboxConfig, } configMap := &corev1.ConfigMap{} if err := c.Get(ctx, key, configMap); err != nil { return "", err } openboxConf := "" if configMap.Data != nil { openboxConf = configMap.Data[openboxConfKey] } if openboxConf == "" { return "", errOpenboxConfCannotBeEmpty } return openboxConf, nil } // Generates the X server config ConfigMap name for a hostname. // // nolint: revive func ConfigMapNameFromHostname(hostname string) string { return fmt.Sprintf("%s-%s", xServerConfig, hostname) } // Parses the X server config ConfigMap name, returning the hostname. // // Returns an error name if not in format "xserver-config-HOSTNAME". func HostnameFromConfigMapName(name string) (string, error) { hostname := strings.TrimPrefix(name, fmt.Sprintf("%s-", xServerConfig)) if name == hostname { return "", fmt.Errorf("%w, got: '%s'", errXServerConfigMapNameHasFromFormat, name) } return hostname, nil } // Updates file contents if it has changed. // If the directory does not exist, it will be created. func safeUpdateFile(path, contents string, mode fs.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), mode); err != nil { return err } currentData, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } newData := []byte(contents) if bytes.Equal(currentData, newData) && currentData != nil { return nil } return os.WriteFile(path, newData, mode) }