mirror of
https://github.com/clearlinux/clr-installer.git
synced 2026-09-02 19:51:30 +00:00
Fixes: #600 Only copy the swupd configuration files from the host to the target by default when running the interactive installer (gui or tui). Otherwise, observe the setting from the configuration YAML or the command line (highest) argument. Signed-off-by: Mark D Horn <mark.d.horn@intel.com>
619 lines
18 KiB
Go
619 lines
18 KiB
Go
// Copyright © 2019 Intel Corporation
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v2"
|
|
|
|
"github.com/clearlinux/clr-installer/args"
|
|
"github.com/clearlinux/clr-installer/errors"
|
|
"github.com/clearlinux/clr-installer/kernel"
|
|
"github.com/clearlinux/clr-installer/keyboard"
|
|
"github.com/clearlinux/clr-installer/language"
|
|
"github.com/clearlinux/clr-installer/network"
|
|
"github.com/clearlinux/clr-installer/storage"
|
|
"github.com/clearlinux/clr-installer/telemetry"
|
|
"github.com/clearlinux/clr-installer/timezone"
|
|
"github.com/clearlinux/clr-installer/user"
|
|
"github.com/clearlinux/clr-installer/utils"
|
|
)
|
|
|
|
const (
|
|
// DemoVersion is hard coded string we display in log files
|
|
// when running in demo (aka documentation mode). We will
|
|
// now use this as a flag to not include the version in UI.
|
|
DemoVersion = "X.Y.Z"
|
|
)
|
|
|
|
// Version of Clear Installer.
|
|
// Also used by the Makefile for releases.
|
|
// Default to the version of the program
|
|
// but may be overridden for demo/documentation mode.
|
|
// Set by Go linker in the Makefile
|
|
var Version = "undefined"
|
|
|
|
// BuildDate is set by the Go linker with the build datetime
|
|
var BuildDate = "undefined"
|
|
var testAlias = []string{}
|
|
|
|
// SystemInstall represents the system install "configuration", the target
|
|
// medias, bundles to install and whatever state a install may require
|
|
type SystemInstall struct {
|
|
InstallSelected map[string]storage.InstallTarget `yaml:"-"`
|
|
TargetMedias []*storage.BlockDevice `yaml:"targetMedia"`
|
|
NetworkInterfaces []*network.Interface `yaml:"networkInterfaces,omitempty,flow"`
|
|
Keyboard *keyboard.Keymap `yaml:"keyboard,omitempty,flow"`
|
|
Language *language.Language `yaml:"language,omitempty,flow"`
|
|
Bundles []string `yaml:"bundles,omitempty,flow"`
|
|
TargetBundles []string `yaml:"targetBundles,omitempty,flow"`
|
|
UserBundles []string `yaml:"userBundles,omitempty,flow"`
|
|
Offline bool `yaml:"offline,omitempty,flow"`
|
|
HTTPSProxy string `yaml:"httpsProxy,omitempty,flow"`
|
|
Telemetry *telemetry.Telemetry `yaml:"telemetry,omitempty,flow"`
|
|
Timezone *timezone.TimeZone `yaml:"timezone,omitempty,flow"`
|
|
Users []*user.User `yaml:"users,omitempty,flow"`
|
|
KernelArguments *kernel.Arguments `yaml:"kernel-arguments,omitempty,flow"`
|
|
Kernel *kernel.Kernel `yaml:"kernel,omitempty,flow"`
|
|
PostReboot bool `yaml:"postReboot,omitempty,flow"`
|
|
SwupdMirror string `yaml:"swupdMirror,omitempty,flow"`
|
|
AllowInsecureHTTP bool `yaml:"AllowInsecureHTTP,omitempty,flow"`
|
|
SwupdSkipOptional bool `yaml:"swupdSkipOptional,omitempty,flow"`
|
|
PostArchive bool `yaml:"postArchive,omitempty,flow"`
|
|
Hostname string `yaml:"hostname,omitempty,flow"`
|
|
AutoUpdate bool `yaml:"autoUpdate,flow"`
|
|
TelemetryURL string `yaml:"telemetryURL,omitempty,flow"`
|
|
TelemetryTID string `yaml:"telemetryTID,omitempty,flow"`
|
|
TelemetryPolicy string `yaml:"telemetryPolicy,omitempty,flow"`
|
|
PreInstall []*InstallHook `yaml:"pre-install,omitempty,flow"`
|
|
PostInstall []*InstallHook `yaml:"post-install,omitempty,flow"`
|
|
SwupdFormat string `yaml:"swupdFormat,omitempty,flow"`
|
|
Version uint `yaml:"version,omitempty,flow"`
|
|
StorageAlias []*StorageAlias `yaml:"block-devices,omitempty,flow"`
|
|
LegacyBios bool `yaml:"legacyBios,omitempty,flow"`
|
|
CopyNetwork bool `yaml:"copyNetwork,omitempty,flow"`
|
|
CopySwupd bool `yaml:"copySwupd,omitempty,flow"`
|
|
Environment map[string]string `yaml:"env,omitempty,flow"`
|
|
CryptPass string `yaml:"-"`
|
|
MakeISO bool `yaml:"iso,omitempty,flow"`
|
|
KeepImage bool `yaml:"keepImage,omitempty,flow"`
|
|
LockFile string `yaml:"-"`
|
|
ClearCfFile string `yaml:"-"`
|
|
PreCheckDone bool `yaml:"preCheckDone,omitempty,flow"`
|
|
}
|
|
|
|
// SystemUsage is used to include additional information into the telemetry payload
|
|
type SystemUsage struct {
|
|
InstallModel SystemInstall `yaml:",inline"`
|
|
Hypervisor string `yaml:"hypervisor,omitempty,flow"`
|
|
}
|
|
|
|
// InstallHook is a commands to be executed in a given point of the install process
|
|
type InstallHook struct {
|
|
Chroot bool `yaml:"chroot,omitempty,flow"`
|
|
Cmd string `yaml:"cmd,omitempty,flow"`
|
|
}
|
|
|
|
// StorageAlias is used to expand variables in the targetMedia definitions
|
|
// a partition's block device name attribute could be declared in the form of:
|
|
// Name: ${alias}p1
|
|
// where ${alias} was previously declared pointing to a block device file such as:
|
|
// block-devices : [
|
|
// {name: "alias", file: "/dev/nvme0n1"}
|
|
// ]
|
|
type StorageAlias struct {
|
|
Name string `yaml:"name,omitempty,flow"`
|
|
File string `yaml:"file,omitempty,flow"`
|
|
DeviceFile bool `yaml:"devicefile,omitempty,flow"`
|
|
}
|
|
|
|
// ClearInstallSelected clears the map of Installation Selected targets
|
|
func (si *SystemInstall) ClearInstallSelected() {
|
|
si.InstallSelected = map[string]storage.InstallTarget{}
|
|
}
|
|
|
|
// ClearExtraKernelArguments clears all of the of custom extra kernel arguments
|
|
func (si *SystemInstall) ClearExtraKernelArguments() {
|
|
if si.KernelArguments == nil {
|
|
si.KernelArguments = &kernel.Arguments{}
|
|
}
|
|
|
|
si.KernelArguments.Add = []string{}
|
|
}
|
|
|
|
// AddExtraKernelArguments adds a set of custom extra kernel arguments to be added to the
|
|
// clr-boot-manager configuration
|
|
func (si *SystemInstall) AddExtraKernelArguments(args []string) {
|
|
if si.KernelArguments == nil {
|
|
si.KernelArguments = &kernel.Arguments{}
|
|
}
|
|
|
|
for _, curr := range args {
|
|
if utils.StringSliceContains(si.KernelArguments.Add, curr) {
|
|
continue
|
|
}
|
|
|
|
si.KernelArguments.Add = append(si.KernelArguments.Add, curr)
|
|
}
|
|
}
|
|
|
|
// ClearRemoveKernelArguments clears all of the of custom remove kernel arguments
|
|
func (si *SystemInstall) ClearRemoveKernelArguments() {
|
|
if si.KernelArguments == nil {
|
|
si.KernelArguments = &kernel.Arguments{}
|
|
}
|
|
|
|
si.KernelArguments.Remove = []string{}
|
|
}
|
|
|
|
// RemoveKernelArguments adds a set of kernel arguments to be "black listed" on
|
|
// clear-boot-manager, meaning these arguments will never end up in the boot manager
|
|
// entry configuration
|
|
func (si *SystemInstall) RemoveKernelArguments(args []string) {
|
|
if si.KernelArguments == nil {
|
|
si.KernelArguments = &kernel.Arguments{}
|
|
}
|
|
|
|
for _, curr := range args {
|
|
if utils.StringSliceContains(si.KernelArguments.Remove, curr) {
|
|
continue
|
|
}
|
|
|
|
si.KernelArguments.Remove = append(si.KernelArguments.Remove, curr)
|
|
}
|
|
}
|
|
|
|
// ContainsBundle returns true if the data model has a bundle and false otherwise
|
|
func (si *SystemInstall) ContainsBundle(bundle string) bool {
|
|
for _, curr := range si.Bundles {
|
|
if curr == bundle {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RemoveBundle removes a bundle from the data model
|
|
func (si *SystemInstall) RemoveBundle(bundle string) {
|
|
bundles := []string{}
|
|
|
|
for _, curr := range si.Bundles {
|
|
if curr != bundle {
|
|
bundles = append(bundles, curr)
|
|
}
|
|
}
|
|
|
|
si.Bundles = bundles
|
|
}
|
|
|
|
// AddBundle adds a new bundle to the data model, we make sure to not duplicate entries
|
|
func (si *SystemInstall) AddBundle(bundle string) {
|
|
for _, curr := range si.Bundles {
|
|
if curr == bundle {
|
|
return
|
|
}
|
|
}
|
|
|
|
si.Bundles = append(si.Bundles, bundle)
|
|
}
|
|
|
|
// ContainsUserBundle returns true if the data model has a user bundle and false otherwise
|
|
func (si *SystemInstall) ContainsUserBundle(bundle string) bool {
|
|
for _, curr := range si.UserBundles {
|
|
if curr == bundle {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RemoveUserBundle removes a user bundle from the data model
|
|
func (si *SystemInstall) RemoveUserBundle(bundle string) {
|
|
bundles := []string{}
|
|
|
|
for _, curr := range si.UserBundles {
|
|
if curr != bundle {
|
|
bundles = append(bundles, curr)
|
|
}
|
|
}
|
|
|
|
si.UserBundles = bundles
|
|
}
|
|
|
|
// AddUserBundle adds a new user bundle to the data model, we make sure to not duplicate entries
|
|
func (si *SystemInstall) AddUserBundle(bundle string) {
|
|
for _, curr := range si.UserBundles {
|
|
if curr == bundle {
|
|
return
|
|
}
|
|
}
|
|
|
|
si.UserBundles = append([]string{bundle}, si.UserBundles...)
|
|
}
|
|
|
|
// OverrideBundles replaces the current bundles with the override list
|
|
// Sets the kernel to one of the kernel bundles, the rest remain in the list
|
|
func (si *SystemInstall) OverrideBundles(overrideBundles []string) {
|
|
var kernelBundles []string
|
|
si.Bundles = []string{} // Clear any existing bundles
|
|
|
|
for _, bundle := range overrideBundles {
|
|
if strings.HasPrefix(bundle, "kernel-") {
|
|
kernelBundles = append(kernelBundles, bundle)
|
|
continue
|
|
}
|
|
si.Bundles = append(si.Bundles, bundle) // Set Bundles
|
|
}
|
|
|
|
// Sort the kernels as we want one that had the CDROM loadable
|
|
// kernel modules by default: native or LTS
|
|
sort.Sort(sort.Reverse(sort.StringSlice(kernelBundles)))
|
|
|
|
var foundKernel bool
|
|
for _, bundle := range kernelBundles {
|
|
// Only use the first kernel bundle
|
|
if !foundKernel {
|
|
foundKernel = true
|
|
si.Kernel = &kernel.Kernel{Bundle: bundle}
|
|
} else {
|
|
si.Bundles = append(si.Bundles, bundle) // Add extra kernel bundles
|
|
fmt.Printf("WARNING: Extra kernel bundle '%s' detected; '%s' already in use.\n",
|
|
bundle, si.Kernel.Bundle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RemoveAllUsers remove from the data model all previously added user
|
|
func (si *SystemInstall) RemoveAllUsers() {
|
|
si.Users = []*user.User{}
|
|
}
|
|
|
|
// AddUser adds a new user to the data model, this function also prevents duplicate entries
|
|
func (si *SystemInstall) AddUser(usr *user.User) {
|
|
for _, curr := range si.Users {
|
|
if curr.Equals(usr) {
|
|
return
|
|
}
|
|
}
|
|
|
|
si.Users = append(si.Users, usr)
|
|
}
|
|
|
|
// EncryptionRequiresPassphrase checks all partition to see if encryption was enabled
|
|
func (si *SystemInstall) EncryptionRequiresPassphrase() bool {
|
|
enabled := false
|
|
|
|
for _, curr := range si.TargetMedias {
|
|
enabled = enabled || curr.EncryptionRequiresPassphrase()
|
|
}
|
|
|
|
return enabled
|
|
}
|
|
|
|
// Validate checks the model for possible inconsistencies or "minimum required"
|
|
// information
|
|
func (si *SystemInstall) Validate() error {
|
|
// si will be nil if we fail to unmarshall (coverage tests has a case for that)
|
|
if si == nil {
|
|
return errors.ValidationErrorf("model is nil")
|
|
}
|
|
|
|
if si.TargetMedias == nil || len(si.TargetMedias) == 0 {
|
|
return errors.ValidationErrorf("System Installation must provide a target media")
|
|
}
|
|
|
|
if err := storage.Validate(si.TargetMedias, si.LegacyBios, si.CryptPass); err != nil {
|
|
return err
|
|
}
|
|
|
|
if si.Timezone == nil {
|
|
return errors.ValidationErrorf("Timezone not set")
|
|
}
|
|
|
|
if si.Keyboard == nil {
|
|
return errors.ValidationErrorf("Keyboard not set")
|
|
}
|
|
|
|
if si.Language == nil {
|
|
return errors.ValidationErrorf("System Language not set")
|
|
}
|
|
|
|
if si.Telemetry == nil {
|
|
return errors.ValidationErrorf("Telemetry not acknowledged")
|
|
}
|
|
|
|
if si.Kernel == nil {
|
|
return errors.ValidationErrorf("A kernel must be provided")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddTargetMedia adds a BlockDevice instance to the list of TargetMedias
|
|
// if bd was previously added to as a target media its pointer is updated
|
|
func (si *SystemInstall) AddTargetMedia(bd *storage.BlockDevice) {
|
|
if si.TargetMedias == nil {
|
|
si.TargetMedias = []*storage.BlockDevice{}
|
|
}
|
|
|
|
nList := []*storage.BlockDevice{bd}
|
|
|
|
for _, curr := range si.TargetMedias {
|
|
if !bd.Equals(curr) {
|
|
nList = append(nList, curr)
|
|
}
|
|
}
|
|
|
|
si.TargetMedias = nList
|
|
}
|
|
|
|
// AddNetworkInterface adds an Interface instance to the list of NetworkInterfaces
|
|
func (si *SystemInstall) AddNetworkInterface(iface *network.Interface) {
|
|
if si.NetworkInterfaces == nil {
|
|
si.NetworkInterfaces = []*network.Interface{}
|
|
}
|
|
|
|
si.NetworkInterfaces = append(si.NetworkInterfaces, iface)
|
|
}
|
|
|
|
// LoadFile loads a model from a yaml file pointed by path
|
|
func LoadFile(path string, options args.Args) (*SystemInstall, error) {
|
|
var result SystemInstall
|
|
|
|
// Default to archiving by default
|
|
result.PostArchive = true
|
|
|
|
// Default to Auto Updating enabled by default
|
|
result.AutoUpdate = true
|
|
|
|
if _, err := os.Stat(path); err == nil {
|
|
configStr, err := ioutil.ReadFile(path)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err)
|
|
}
|
|
|
|
err = yaml.Unmarshal(configStr, &result)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Set default Timezone if not defined
|
|
if result.Timezone == nil {
|
|
result.Timezone = &timezone.TimeZone{Code: timezone.DefaultTimezone}
|
|
}
|
|
|
|
// Set default Keyboard if not defined
|
|
if result.Keyboard == nil {
|
|
result.Keyboard = &keyboard.Keymap{Code: keyboard.DefaultKeyboard}
|
|
}
|
|
|
|
// Set default Language if not defined
|
|
if result.Language == nil {
|
|
result.Language = &language.Language{Code: language.DefaultLanguage}
|
|
}
|
|
|
|
// Running in VirtualBox force the default to 'kernel-lts' if
|
|
// we are using the system default configuration file
|
|
// See https://github.com/clearlinux/clr-installer/issues/203
|
|
if options.ConfigFile == "" && utils.IsVirtualBox() {
|
|
result.Kernel = &kernel.Kernel{Bundle: "kernel-lts"}
|
|
}
|
|
|
|
tmp := map[string]*StorageAlias{}
|
|
|
|
for _, bds := range result.StorageAlias {
|
|
tmp[bds.Name] = bds
|
|
}
|
|
|
|
for _, bds := range options.BlockDevices {
|
|
var tks []string
|
|
|
|
if tks = strings.Split(bds, ":"); len(tks) < 2 {
|
|
continue
|
|
}
|
|
|
|
tmp[tks[0]] = &StorageAlias{Name: tks[0], File: tks[1]}
|
|
}
|
|
|
|
result.StorageAlias = []*StorageAlias{}
|
|
|
|
for _, bds := range tmp {
|
|
result.StorageAlias = append(result.StorageAlias, bds)
|
|
}
|
|
|
|
if len(result.StorageAlias) > 0 {
|
|
alias := map[string]string{}
|
|
keepMe := []*StorageAlias{}
|
|
|
|
for _, curr := range result.StorageAlias {
|
|
if !isAliasInUse(result.TargetMedias, curr) {
|
|
continue
|
|
}
|
|
|
|
fi, err := os.Lstat(curr.File)
|
|
inTestAlias := isTestAlias(curr.File)
|
|
|
|
// could be an image file to be created so we fail only if the error doesn't
|
|
// indicate the image file doesn't exist
|
|
if err != nil && !inTestAlias && !os.IsNotExist(err) {
|
|
return nil, errors.Wrap(err)
|
|
}
|
|
|
|
keepMe = append(keepMe, curr)
|
|
|
|
if !inTestAlias && os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
if (fi != nil && fi.Mode()&os.ModeDevice == 0) && !inTestAlias {
|
|
continue
|
|
}
|
|
|
|
curr.DeviceFile = true
|
|
alias[curr.Name] = filepath.Base(curr.File)
|
|
}
|
|
|
|
// keep only the aliases we're using
|
|
result.StorageAlias = keepMe
|
|
|
|
for _, bd := range result.TargetMedias {
|
|
bd.ExpandName(alias)
|
|
}
|
|
}
|
|
|
|
if result.Version > 0 {
|
|
result.AutoUpdate = false
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
func isAliasInUse(bds []*storage.BlockDevice, alias *StorageAlias) bool {
|
|
for _, curr := range bds {
|
|
rep := fmt.Sprintf("${%s}", alias.Name)
|
|
|
|
if strings.Contains(curr.Name, rep) {
|
|
return true
|
|
}
|
|
|
|
if isAliasInUse(curr.Children, alias) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isTestAlias(file string) bool {
|
|
if len(testAlias) == 0 {
|
|
return false
|
|
}
|
|
|
|
return utils.StringSliceContains(testAlias, file)
|
|
}
|
|
|
|
// EnableTelemetry operates on the telemetry flag and enables or disables the target
|
|
// systems telemetry support based in enable argument
|
|
func (si *SystemInstall) EnableTelemetry(enable bool) {
|
|
if si.Telemetry == nil {
|
|
si.Telemetry = &telemetry.Telemetry{}
|
|
}
|
|
|
|
si.Telemetry.SetEnable(enable)
|
|
}
|
|
|
|
// IsTelemetryEnabled returns true if telemetry is enabled, false otherwise
|
|
func (si *SystemInstall) IsTelemetryEnabled() bool {
|
|
if si.Telemetry == nil {
|
|
return false
|
|
}
|
|
|
|
return si.Telemetry.Enabled
|
|
}
|
|
|
|
// IsTelemetryInstalled return true if telemetry tooling is present, false otherwise
|
|
func (si *SystemInstall) IsTelemetryInstalled() bool {
|
|
return si.Telemetry.Installed()
|
|
}
|
|
|
|
// WriteFile writes a yaml formatted representation of si into the provided file path
|
|
func (si *SystemInstall) WriteFile(path string) error {
|
|
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
_ = f.Close()
|
|
}()
|
|
|
|
b, err := yaml.Marshal(si)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write our header
|
|
_, err = f.WriteString("#clear-linux-config\n")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Write our version
|
|
_, err = f.WriteString("#generated by clr-installer:" + Version + "\n")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Write datetime stamp
|
|
t := time.Now().UTC()
|
|
_, err = f.WriteString("#generated on: " + fmt.Sprintf("%d-%02d-%02d_%02d:%02d:%02d_UTC\n",
|
|
t.Year(), t.Month(), t.Day(),
|
|
t.Hour(), t.Minute(), t.Second()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = f.Write(b)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteScrubModelTargetMedias writes out a copy the model with the
|
|
// TargetMedias removed to a temporary file
|
|
func (si *SystemInstall) WriteScrubModelTargetMedias() (string, error) {
|
|
// Sanitized the model to remove media
|
|
var cleanModel SystemInstall
|
|
|
|
// Marshal current into bytes
|
|
confBytes, bytesErr := yaml.Marshal(si)
|
|
if bytesErr != nil {
|
|
return "", errors.Wrap(bytesErr)
|
|
}
|
|
|
|
// Unmarshal into a copy
|
|
if yamlErr := yaml.Unmarshal(confBytes, &cleanModel); yamlErr != nil {
|
|
return "", errors.Wrap(bytesErr)
|
|
}
|
|
|
|
// Sanitize the config data to remove any potential
|
|
// Remove the target media
|
|
cleanModel.TargetMedias = nil
|
|
|
|
tmpYaml, err := ioutil.TempFile("", "clr-installer-noMedia-*.yaml")
|
|
if err != nil {
|
|
return "", errors.Errorf("Could not make YAML tempfile: %v", err)
|
|
}
|
|
|
|
if saveErr := cleanModel.WriteFile(tmpYaml.Name()); saveErr != nil {
|
|
return "", errors.Errorf("Could not save config to %s", tmpYaml.Name())
|
|
}
|
|
|
|
return tmpYaml.Name(), nil
|
|
}
|
|
|
|
// InteractiveOptionsValid ensures that options which are not appropriate
|
|
// for interactive runs are screened
|
|
func (si *SystemInstall) InteractiveOptionsValid() error {
|
|
if si.Offline {
|
|
return fmt.Errorf("Incompatible flag '--offline' for the interactive installer")
|
|
}
|
|
if si.MakeISO {
|
|
return fmt.Errorf("Incompatible flag '--iso' for the interactive installer")
|
|
}
|
|
|
|
return nil
|
|
}
|