mirror of
https://github.com/lxc/incus
synced 2026-08-02 05:26:46 +00:00
It's a valid configuration to set ipv4.address to a /32 or ipv6.address to a /128 as LXC will make the gateway as an on-link gateway. This is particularly useful when dealing with public addresses. Signed-off-by: Stéphane Graber <stgraber@stgraber.org>
627 lines
19 KiB
Go
627 lines
19 KiB
Go
//go:build linux
|
|
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os/exec"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.yaml.in/yaml/v4"
|
|
"golang.org/x/sys/unix"
|
|
|
|
incus "github.com/lxc/incus/v7/client"
|
|
"github.com/lxc/incus/v7/internal/i18n"
|
|
"github.com/lxc/incus/v7/internal/linux"
|
|
"github.com/lxc/incus/v7/internal/ports"
|
|
internalUtil "github.com/lxc/incus/v7/internal/util"
|
|
"github.com/lxc/incus/v7/shared/api"
|
|
"github.com/lxc/incus/v7/shared/subprocess"
|
|
"github.com/lxc/incus/v7/shared/util"
|
|
"github.com/lxc/incus/v7/shared/validate"
|
|
)
|
|
|
|
func (c *cmdAdminInit) runInteractive(_ *cobra.Command, d incus.InstanceServer, server *api.Server) (*api.InitPreseed, error) {
|
|
// Initialize config
|
|
config := newInitPressed()
|
|
|
|
// Clustering
|
|
clustering, err := c.global.asker.AskBool(i18n.G("Would you like to use clustering?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if clustering {
|
|
err := askClustering(c.global.asker, config, nil, d, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Ask all the other questions
|
|
if config.Cluster == nil || config.Cluster.ClusterAddress == "" {
|
|
// Storage
|
|
err = c.askStorage(config, d, server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Networking
|
|
err = c.askNetworking(config, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Daemon config
|
|
err = c.askDaemon(config, server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Print the YAML
|
|
preSeedPrint, err := c.global.asker.AskBool(i18n.G("Would you like a YAML \"init\" preseed to be printed?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if preSeedPrint {
|
|
var object api.InitPreseed
|
|
|
|
// If the user has chosen to join an existing cluster, print
|
|
// only YAML for the cluster section, which is the only
|
|
// relevant one. Otherwise print the regular config.
|
|
if config.Cluster != nil && config.Cluster.ClusterAddress != "" {
|
|
object = api.InitPreseed{}
|
|
object.Cluster = config.Cluster
|
|
} else {
|
|
object = *config
|
|
}
|
|
|
|
out, err := yaml.Dump(object, yaml.WithV2Defaults())
|
|
if err != nil {
|
|
return nil, fmt.Errorf(i18n.G("Failed to render the config: %w"), err)
|
|
}
|
|
|
|
fmt.Printf("%s\n", out)
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
func (c *cmdAdminInit) askNetworking(config *api.InitPreseed, d incus.InstanceServer) error {
|
|
var err error
|
|
localBridgeCreate := false
|
|
|
|
if config.Cluster == nil {
|
|
localBridgeCreate, err = c.global.asker.AskBool(i18n.G("Would you like to create a new local network bridge?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !localBridgeCreate {
|
|
useExistingInterface, err := c.global.asker.AskBool(i18n.G("Would you like to use an existing bridge or host interface?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if useExistingInterface {
|
|
for {
|
|
interfaceName, err := c.global.asker.AskString(i18n.G("Name of the existing bridge or host interface:")+" ", "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !util.PathExists(fmt.Sprintf("/sys/class/net/%s", interfaceName)) {
|
|
fmt.Println(i18n.G("The requested interface doesn't exist. Please choose another one."))
|
|
continue
|
|
}
|
|
|
|
// Add to the default profile
|
|
config.Profiles[0].Devices["eth0"] = map[string]string{
|
|
"type": "nic",
|
|
"nictype": "macvlan",
|
|
"name": "eth0",
|
|
"parent": interfaceName,
|
|
}
|
|
|
|
if util.PathExists(fmt.Sprintf("/sys/class/net/%s/bridge", interfaceName)) {
|
|
config.Profiles[0].Devices["eth0"]["nictype"] = "bridged"
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
// Define the network
|
|
network := api.InitNetworksProjectPost{}
|
|
network.Config = map[string]string{}
|
|
network.Project = api.ProjectDefaultName
|
|
|
|
// Network name
|
|
network.Name, err = c.global.asker.AskString(i18n.G("What should the new bridge be called?")+" [default=incusbr0]: ", "incusbr0", validate.IsInterfaceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _, err = d.GetNetwork(network.Name)
|
|
if err == nil {
|
|
fmt.Printf(i18n.G("The requested network bridge \"%s\" already exists. Please choose another name.")+"\n", network.Name)
|
|
continue
|
|
}
|
|
|
|
// Add to the default profile
|
|
config.Profiles[0].Devices["eth0"] = map[string]string{
|
|
"type": "nic",
|
|
"name": "eth0",
|
|
"network": network.Name,
|
|
}
|
|
|
|
// IPv4
|
|
network.Config["ipv4.address"], err = c.global.asker.AskString(i18n.G("What IPv4 address should be used?")+" (CIDR subnet notation, “auto” or “none”) [default=auto]: ", "auto", func(value string) error {
|
|
if slices.Contains([]string{"auto", "none", ""}, value) {
|
|
return nil
|
|
}
|
|
|
|
return validate.IsNetworkAddressCIDRV4(value, false)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !slices.Contains([]string{"auto", "none"}, network.Config["ipv4.address"]) {
|
|
netIPv4UseNAT, err := c.global.asker.AskBool(i18n.G("Would you like to NAT IPv4 traffic on your bridge?")+" [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
network.Config["ipv4.nat"] = fmt.Sprintf("%v", netIPv4UseNAT)
|
|
}
|
|
|
|
// IPv6
|
|
network.Config["ipv6.address"], err = c.global.asker.AskString(i18n.G("What IPv6 address should be used?")+" (CIDR subnet notation, “auto” or “none”) [default=auto]: ", "auto", func(value string) error {
|
|
if slices.Contains([]string{"auto", "none", ""}, value) {
|
|
return nil
|
|
}
|
|
|
|
return validate.IsNetworkAddressCIDRV6(value, false)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !slices.Contains([]string{"auto", "none"}, network.Config["ipv6.address"]) {
|
|
netIPv6UseNAT, err := c.global.asker.AskBool(i18n.G("Would you like to NAT IPv6 traffic on your bridge?")+" [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
network.Config["ipv6.nat"] = fmt.Sprintf("%v", netIPv6UseNAT)
|
|
}
|
|
|
|
// Add the new network
|
|
config.Networks = append(config.Networks, network)
|
|
break
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *cmdAdminInit) askStorage(config *api.InitPreseed, d incus.InstanceServer, server *api.Server) error {
|
|
if config.Cluster != nil {
|
|
localStoragePool, err := c.global.asker.AskBool(i18n.G("Do you want to configure a new local storage pool?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if localStoragePool {
|
|
err := c.askStoragePool(config, d, server, internalUtil.PoolTypeLocal)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
remoteStoragePool, err := c.global.asker.AskBool(i18n.G("Do you want to configure a new remote storage pool?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if remoteStoragePool {
|
|
err := c.askStoragePool(config, d, server, internalUtil.PoolTypeRemote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
storagePool, err := c.global.asker.AskBool(i18n.G("Do you want to configure a new storage pool?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !storagePool {
|
|
return nil
|
|
}
|
|
|
|
return c.askStoragePool(config, d, server, internalUtil.PoolTypeAny)
|
|
}
|
|
|
|
func (c *cmdAdminInit) askStoragePool(config *api.InitPreseed, d incus.InstanceServer, server *api.Server, poolType internalUtil.PoolType) error {
|
|
// Figure out the preferred storage driver
|
|
availableBackends := linux.AvailableStorageDrivers(internalUtil.VarPath(), server.Environment.StorageSupportedDrivers, poolType)
|
|
|
|
if len(availableBackends) == 0 {
|
|
if poolType != internalUtil.PoolTypeAny {
|
|
return errors.New(i18n.G("No storage backends available"))
|
|
}
|
|
|
|
return fmt.Errorf(i18n.G("No %s storage backends available"), poolType)
|
|
}
|
|
|
|
backingFs, err := linux.DetectFilesystem(internalUtil.VarPath())
|
|
if err != nil {
|
|
backingFs = "dir"
|
|
}
|
|
|
|
defaultStorage := "dir"
|
|
if backingFs == "btrfs" && slices.Contains(availableBackends, "btrfs") {
|
|
defaultStorage = "btrfs"
|
|
} else if slices.Contains(availableBackends, "zfs") {
|
|
defaultStorage = "zfs"
|
|
} else if slices.Contains(availableBackends, "btrfs") {
|
|
defaultStorage = "btrfs"
|
|
}
|
|
|
|
for {
|
|
// Define the pool
|
|
pool := api.StoragePoolsPost{}
|
|
pool.Config = map[string]string{}
|
|
|
|
if poolType == internalUtil.PoolTypeAny {
|
|
pool.Name, err = c.global.asker.AskString(i18n.G("Name of the new storage pool")+" [default=default]: ", "default", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
pool.Name = string(poolType)
|
|
}
|
|
|
|
_, _, err := d.GetStoragePool(pool.Name)
|
|
if err == nil {
|
|
if poolType == internalUtil.PoolTypeAny {
|
|
fmt.Printf(i18n.G("The requested storage pool \"%s\" already exists. Please choose another name.")+"\n", pool.Name)
|
|
continue
|
|
}
|
|
|
|
return fmt.Errorf(i18n.G("The %s storage pool already exists"), poolType)
|
|
}
|
|
|
|
// Add to the default profile
|
|
if config.Profiles[0].Devices["root"] == nil {
|
|
config.Profiles[0].Devices["root"] = map[string]string{
|
|
"type": "disk",
|
|
"path": "/",
|
|
"pool": pool.Name,
|
|
}
|
|
}
|
|
|
|
// Storage backend
|
|
if len(availableBackends) > 1 {
|
|
defaultBackend := defaultStorage
|
|
if poolType == internalUtil.PoolTypeRemote {
|
|
if slices.Contains(availableBackends, "ceph") {
|
|
defaultBackend = "ceph"
|
|
} else {
|
|
defaultBackend = availableBackends[0] // Default to first remote driver.
|
|
}
|
|
}
|
|
|
|
pool.Driver, err = c.global.asker.AskChoice(fmt.Sprintf(i18n.G("Name of the storage backend to use (%s)")+" [default=%s]: ", strings.Join(availableBackends, ", "), defaultBackend), availableBackends, defaultBackend)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
pool.Driver = availableBackends[0]
|
|
}
|
|
|
|
// Optimization for dir
|
|
if pool.Driver == "dir" {
|
|
source, err := c.global.asker.AskString(fmt.Sprintf(i18n.G("Where should this storage pool store its data?")+" [default=%s]: ", internalUtil.VarPath("storage-pools", pool.Name)), "", validate.IsAny)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if source != "" {
|
|
pool.Config["source"] = source
|
|
}
|
|
|
|
config.StoragePools = append(config.StoragePools, pool)
|
|
break
|
|
}
|
|
|
|
// Optimization for btrfs on btrfs
|
|
if pool.Driver == "btrfs" && backingFs == "btrfs" {
|
|
btrfsSubvolume, err := c.global.asker.AskBool(fmt.Sprintf(i18n.G("Would you like to create a new btrfs subvolume under %s?")+" (yes/no) [default=yes]: ", internalUtil.VarPath("")), "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if btrfsSubvolume {
|
|
pool.Config["source"] = internalUtil.VarPath("storage-pools", pool.Name)
|
|
config.StoragePools = append(config.StoragePools, pool)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Optimization for zfs on zfs (when using Ubuntu's bpool/rpool)
|
|
if pool.Driver == "zfs" && backingFs == "zfs" {
|
|
poolName, _ := subprocess.RunCommand("zpool", "get", "-H", "-o", "value", "name", "rpool")
|
|
if strings.TrimSpace(poolName) == "rpool" {
|
|
zfsDataset, err := c.global.asker.AskBool(i18n.G("Would you like to create a new zfs dataset under rpool/incus?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if zfsDataset {
|
|
pool.Config["source"] = "rpool/incus"
|
|
config.StoragePools = append(config.StoragePools, pool)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
poolCreate, err := c.global.asker.AskBool(fmt.Sprintf(i18n.G("Create a new %s pool?")+" (yes/no) [default=yes]: ", strings.ToUpper(pool.Driver)), "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if poolCreate {
|
|
switch pool.Driver {
|
|
case "ceph":
|
|
// Ask for the name of the cluster
|
|
pool.Config["ceph.cluster_name"], err = c.global.asker.AskString(i18n.G("Name of the existing CEPH cluster")+" [default=ceph]: ", "ceph", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ask for the name of the osd pool
|
|
pool.Config["ceph.osd.pool_name"], err = c.global.asker.AskString(i18n.G("Name of the OSD storage pool")+" [default=incus]: ", "incus", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ask for the number of placement groups
|
|
pool.Config["ceph.osd.pg_num"], err = c.global.asker.AskString(i18n.G("Number of placement groups")+" [default=32]: ", "32", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
case "cephfs":
|
|
// Ask for the name of the cluster
|
|
pool.Config["cephfs.cluster_name"], err = c.global.asker.AskString(i18n.G("Name of the existing CEPHfs cluster")+" [default=ceph]: ", "ceph", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ask for the name of the cluster
|
|
pool.Config["source"], err = c.global.asker.AskString(i18n.G("Name of the CEPHfs volume:")+" ", "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
case "lvmcluster":
|
|
// Ask for the volume group
|
|
pool.Config["source"], err = c.global.asker.AskString(i18n.G("Name of the shared LVM volume group:")+" ", "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
default:
|
|
useEmptyBlockDev, err := c.global.asker.AskBool(i18n.G("Would you like to use an existing empty block device (e.g. a disk or partition)?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if useEmptyBlockDev {
|
|
pool.Config["source"], err = c.global.asker.AskString(i18n.G("Path to the existing block device:")+" ", "", func(path string) error {
|
|
if !linux.IsBlockdevPath(path) {
|
|
return fmt.Errorf(i18n.G("%q is not a block device"), path)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
st := unix.Statfs_t{}
|
|
err := unix.Statfs(internalUtil.VarPath(), &st)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Couldn't statfs %s: %w"), internalUtil.VarPath(), err)
|
|
}
|
|
|
|
/* choose 5 GiB < x < 30GiB, where x is 20% of the disk size */
|
|
defaultSize := max(min(uint64(st.Frsize)*st.Blocks/(1024*1024*1024)/5, 30), 5)
|
|
|
|
pool.Config["size"], err = c.global.asker.AskString(
|
|
fmt.Sprintf(i18n.G("Size in GiB of the new loop device")+" (1GiB minimum) [default=%dGiB]: ", defaultSize),
|
|
fmt.Sprintf("%dGiB", defaultSize),
|
|
func(input string) error {
|
|
input = strings.Split(input, "GiB")[0]
|
|
|
|
result, err := strconv.ParseInt(input, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if result < 1 {
|
|
return errors.New(i18n.G("Minimum size is 1GiB"))
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !strings.HasSuffix(pool.Config["size"], "GiB") {
|
|
pool.Config["size"] = fmt.Sprintf("%sGiB", pool.Config["size"])
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if pool.Driver == "ceph" {
|
|
// ask for the name of the cluster
|
|
pool.Config["ceph.cluster_name"], err = c.global.asker.AskString(i18n.G("Name of the existing CEPH cluster")+" [default=ceph]: ", "ceph", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// ask for the name of the existing pool
|
|
pool.Config["source"], err = c.global.asker.AskString(i18n.G("Name of the existing OSD storage pool")+" [default=incus]: ", "incus", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pool.Config["ceph.osd.pool_name"] = pool.Config["source"]
|
|
} else {
|
|
question := fmt.Sprintf(i18n.G("Name of the existing %s pool or dataset:")+" ", strings.ToUpper(pool.Driver))
|
|
pool.Config["source"], err = c.global.asker.AskString(question, "", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if pool.Driver == "lvm" {
|
|
_, err := exec.LookPath("thin_check")
|
|
if err != nil {
|
|
fmt.Print("\n" + i18n.G(`The LVM thin provisioning tools couldn't be found.
|
|
LVM can still be used without thin provisioning but this will disable over-provisioning,
|
|
increase the space requirements and creation time of images, instances and snapshots.
|
|
|
|
If you wish to use thin provisioning, abort now, install the tools from your Linux distribution
|
|
and make sure that your user can see and run the "thin_check" command before running "init" again.`) + "\n\n")
|
|
lvmContinueNoThin, err := c.global.asker.AskBool(i18n.G("Do you want to continue without thin provisioning?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !lvmContinueNoThin {
|
|
return errors.New(i18n.G("The LVM thin provisioning tools couldn't be found on the system"))
|
|
}
|
|
|
|
pool.Config["lvm.use_thinpool"] = "false"
|
|
}
|
|
}
|
|
|
|
config.StoragePools = append(config.StoragePools, pool)
|
|
break
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *cmdAdminInit) askDaemon(config *api.InitPreseed, server *api.Server) error {
|
|
// Detect lack of uid/gid
|
|
if linux.RunningInUserNS() {
|
|
fmt.Print("\n" + i18n.G(`We detected that you are running inside an unprivileged container.
|
|
This means that unless you manually configured your host otherwise,
|
|
you will not have enough uids and gids to allocate to your containers.
|
|
|
|
Your container's own allocation can be reused to avoid the problem.
|
|
Doing so makes your nested containers slightly less safe as they could
|
|
in theory attack their parent container and gain more privileges than
|
|
they otherwise would.`) + "\n\n")
|
|
|
|
shareParentAllocation, err := c.global.asker.AskBool(i18n.G("Would you like to have your containers share their parent's allocation?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if shareParentAllocation {
|
|
config.Profiles[0].Config["security.privileged"] = "true"
|
|
}
|
|
}
|
|
|
|
// Network listener
|
|
if config.Cluster == nil {
|
|
overNetwork, err := c.global.asker.AskBool(i18n.G("Would you like the server to be available over the network?")+" (yes/no) [default=no]: ", "no")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if overNetwork {
|
|
isIPAddress := func(s string) error {
|
|
if s != "all" && net.ParseIP(s) == nil {
|
|
return fmt.Errorf(i18n.G("%q is not an IP address"), s)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
netAddr, err := c.global.asker.AskString(i18n.G("Address to bind to (not including port)")+" [default=all]: ", "all", isIPAddress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if netAddr == "all" {
|
|
netAddr = "::"
|
|
}
|
|
|
|
if net.ParseIP(netAddr).To4() == nil {
|
|
netAddr = fmt.Sprintf("[%s]", netAddr)
|
|
}
|
|
|
|
netPort, err := c.global.asker.AskInt(fmt.Sprintf(i18n.G("Port to bind to")+" [default=%d]: ", ports.HTTPSDefaultPort), 1, 65535, fmt.Sprintf("%d", ports.HTTPSDefaultPort), func(netPort int64) error {
|
|
address := internalUtil.CanonicalNetworkAddressFromAddressAndPort(netAddr, int(netPort), ports.HTTPSDefaultPort)
|
|
|
|
if err == nil {
|
|
if server.Config["cluster.https_address"] == address || server.Config["core.https_address"] == address {
|
|
// We already own the address, just move on.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", address)
|
|
if err != nil {
|
|
return fmt.Errorf(i18n.G("Can't bind address %q: %w"), address, err)
|
|
}
|
|
|
|
_ = listener.Close()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
config.Config["core.https_address"] = internalUtil.CanonicalNetworkAddressFromAddressAndPort(netAddr, int(netPort), ports.HTTPSDefaultPort)
|
|
}
|
|
}
|
|
|
|
// Ask if the user wants images to be automatically refreshed
|
|
imageStaleRefresh, err := c.global.asker.AskBool(i18n.G("Would you like stale cached images to be updated automatically?")+" (yes/no) [default=yes]: ", "yes")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !imageStaleRefresh {
|
|
config.Config["images.auto_update_interval"] = "0"
|
|
}
|
|
|
|
return nil
|
|
}
|