Replace admin create-network with network create over RPC

This commit is contained in:
Brian Picciano 2024-07-09 11:43:17 +02:00
parent f9d033b89f
commit 279c79a9f1
11 changed files with 145 additions and 361 deletions

View File

@ -62,7 +62,7 @@ Operator hosts will need at least one of the following to be useful:
* A static public IP, or a dynamic public IP with [dDNS][ddns] set up. * A static public IP, or a dynamic public IP with [dDNS][ddns] set up.
* At least 100GB of unused storage which can be reserved for the network. (TODO review storage requirements) * At least 1GB of unused storage which can be reserved for the network.
Operators are expected to be familiar with server administration, and to not be Operators are expected to be familiar with server administration, and to not be
afraid of a terminal. afraid of a terminal.

View File

@ -27,16 +27,16 @@ The requirements for this host are:
behind a NAT, and/or allowing traffic on that UDP port in your hosts behind a NAT, and/or allowing traffic on that UDP port in your hosts
firewall. firewall.
* At least 300 GB of disk storage space. (TODO double check the storage space requirements) * At least 3 GB of disk storage space.
* At least 3 directories should be chosen, each of which will be committing at * At least 3 directories should be chosen, each of which will be committing at
least 100GB. Ideally these directories should be on different physical least 1GB. Ideally these directories should be on different physical disks,
disks, but if that's not possible it's ok. See the Next Steps section. but if that's not possible it's ok. See the Next Steps section.
* None of the resources being used for this network (the UDP port or storage * None of the resources being used for this network (the UDP port or storage
locations) should be being used by other networks. locations) should be being used by other networks.
## Step 1: Edit the `daemon.yml` File ## Step 1: Configure the isle Daemon
Open `/etc/isle/daemon.yml` in a text editor and perform the following changes: Open `/etc/isle/daemon.yml` in a text editor and perform the following changes:
@ -48,6 +48,12 @@ Open `/etc/isle/daemon.yml` in a text editor and perform the following changes:
Save and close the file. Save and close the file.
Run the following to restart the daemon with the new configuration:
```
sudo systemctl restart isle
```
## Step 2: Choose Parameters ## Step 2: Choose Parameters
There are some key parameters which must be chosen when creating a new network. There are some key parameters which must be chosen when creating a new network.
@ -92,12 +98,10 @@ if you care to use a different method.
## Step 4: Create the `admin.json` File ## Step 4: Create the `admin.json` File
To create the `admin.json` file, which effectively creates the network itself, To create the network, and the `admin.json` file in the process, run:
you can run:
``` ```
sudo isle admin create-network \ sudo isle network create \
--config-path /etc/isle/daemon.yml \
--name <name> \ --name <name> \
--ip-net <ip/subnet-prefix> \ --ip-net <ip/subnet-prefix> \
--domain <domain> \ --domain <domain> \
@ -111,34 +115,19 @@ A couple of notes here:
* The `--ip-net` parameter is formed from both the subnet and the IP you chose * The `--ip-net` parameter is formed from both the subnet and the IP you chose
within it. So if your subnet is `10.10.0.0/16`, and your chosen IP in that within it. So if your subnet is `10.10.0.0/16`, and your chosen IP in that
subnet is `10.10.4.20`, then your `--ip-net` parameter will be subnet is `10.10.4.20`, then your `--ip-net` parameter will be
`10.10.4.20/16`. (TODO expand a bit on what IP is being chosen). `10.10.4.20/16`.
* Only one gpg recipient is specified. If you intend on including other users as * Only one gpg recipient is specified. If you intend on including other users as
network administrators you can add them to the recipients list at this step, network administrators you can add them to the recipients list at this step,
so they will be able to use the `admin.json` file as well. You can also so they will be able to use the `admin.json` file as well. You can also
manually add them as recipients later. manually add them as recipients later.
You will see a lot of output, as `create-network` starts up many child processes The `isle network create` command may take up to a minute to complete. Once
in order to set the network up. It should exit successfully on its own after a completed you should have an `admin.json.gpg` file in your current directory.
few seconds.
At this point you should have an `admin.json.gpg` file in your current directory. At this point your host, and your network, are ready to go! To add other hosts
to the network you can reference the [Adding a Host to the Network][add-host]
## Step 5: Run the Daemon document.
The isle daemon can be run now, using the following command:
```
sudo isle daemon -c /path/to/daemon.yml
```
**NOTE** that you _must_ use the same `daemon.yml` file used when creating the
network for the daemon itself.
At this point your host, and your network, are ready to go! You can reference
the [Getting Started](../user/getting-started.md) document to set up your
host's daemon process in a more permanent way. (TODO once creating a network is
done via RPC then this will be out-of-date. Better to direct them to the
operator docs, or maybe adding a new host).
[add-host]: ./adding-a-host-to-the-network.md
[ddns]: https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/ [ddns]: https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/

View File

@ -1,8 +1,8 @@
# Contributing Storage # Contributing Storage
If your host machine can be reasonably sure of being online most, if not all, of If your host machine can be reasonably sure of being online most, if not all, of
the time, and has 100GB or more of unused drive space you'd like to contribute the time, and has 1GB or more of unused drive space you'd like to contribute to
to the network, then this document is for you. the network, then this document is for you.
## Edit `daemon.yml` ## Edit `daemon.yml`

View File

@ -7,14 +7,9 @@ import (
"fmt" "fmt"
"isle/admin" "isle/admin"
"isle/bootstrap" "isle/bootstrap"
"isle/daemon"
"isle/garage"
"isle/nebula" "isle/nebula"
"net" "net"
"os" "os"
"strings"
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
) )
func randStr(l int) string { func randStr(l int) string {
@ -46,192 +41,6 @@ func readAdmin(path string) (admin.Admin, error) {
return admin.FromReader(f) return admin.FromReader(f)
} }
var subCmdAdminCreateNetwork = subCmd{
name: "create-network",
descr: "Creates a new isle network, outputting the resulting admin.json to stdout",
do: func(subCmdCtx subCmdCtx) error {
flags := subCmdCtx.flagSet(false)
daemonConfigPath := flags.StringP(
"config-path", "c", "",
"Optional path to a daemon.yml file to load configuration from.",
)
dumpConfig := flags.Bool(
"dump-config", false,
"Write the default configuration file to stdout and exit.",
)
name := flags.StringP(
"name", "n", "",
"Human-readable name to identify the network as.",
)
domain := flags.StringP(
"domain", "d", "",
"Domain name that should be used as the root domain in the network.",
)
ipNetStr := flags.StringP(
"ip-net", "i", "",
`IP+prefix (e.g. "10.10.0.1/16") which denotes the IP of this host, which will be the first host in the network, and the range of IPs which other hosts in the network can be assigned`,
)
hostName := flags.StringP(
"hostname", "h", "",
"Name of this host, which will be the first host in the network",
)
logLevelStr := flags.StringP(
"log-level", "l", "info",
`Maximum log level which should be output. Values can be "debug", "info", "warn", "error", "fatal". Does not apply to sub-processes`,
)
if err := flags.Parse(subCmdCtx.args); err != nil {
return fmt.Errorf("parsing flags: %w", err)
}
ctx := subCmdCtx.ctx
if *dumpConfig {
return daemon.CopyDefaultConfig(os.Stdout, envAppDirPath)
}
if *name == "" || *domain == "" || *ipNetStr == "" || *hostName == "" {
return errors.New("--name, --domain, --ip-net, and --hostname are required")
}
logLevel := mlog.LevelFromString(*logLevelStr)
if logLevel == nil {
return fmt.Errorf("couldn't parse log level %q", *logLevelStr)
}
logger := subCmdCtx.logger.WithMaxLevel(logLevel.Int())
*domain = strings.TrimRight(strings.TrimLeft(*domain, "."), ".")
ip, subnet, err := net.ParseCIDR(*ipNetStr)
if err != nil {
return fmt.Errorf("parsing %q as a CIDR: %w", *ipNetStr, err)
}
if err := validateHostName(*hostName); err != nil {
return fmt.Errorf("invalid hostname %q: %w", *hostName, err)
}
runtimeDirCleanup, err := setupAndLockRuntimeDir(ctx, logger)
if err != nil {
return fmt.Errorf("setting up runtime directory: %w", err)
}
defer runtimeDirCleanup()
daemonConfig, err := daemon.LoadConfig(envAppDirPath, *daemonConfigPath)
if err != nil {
return fmt.Errorf("loading daemon config: %w", err)
}
if len(daemonConfig.Storage.Allocations) < 3 {
return fmt.Errorf("daemon config with at least 3 allocations was not provided")
}
nebulaCACreds, err := nebula.NewCACredentials(*domain, subnet)
if err != nil {
return fmt.Errorf("creating nebula CA cert: %w", err)
}
adminCreationParams := admin.CreationParams{
ID: randStr(32),
Name: *name,
Domain: *domain,
}
garageBootstrap := bootstrap.Garage{
RPCSecret: randStr(32),
AdminToken: randStr(32),
}
hostBootstrap, err := bootstrap.New(
nebulaCACreds,
adminCreationParams,
garageBootstrap,
*hostName,
ip,
)
if err != nil {
return fmt.Errorf("initializing bootstrap data: %w", err)
}
if hostBootstrap, err = coalesceDaemonConfigAndBootstrap(hostBootstrap, daemonConfig); err != nil {
return fmt.Errorf("merging daemon config into bootstrap data: %w", err)
}
children, err := daemon.NewChildren(
ctx,
logger.WithNamespace("daemon"),
daemonConfig,
hostBootstrap,
envBinDirPath,
&daemon.Opts{
// NOTE both stdout and stderr are sent to stderr, so that the
// user can pipe the resulting admin.json to stdout.
Stdout: os.Stderr,
},
)
if err != nil {
return fmt.Errorf("initializing children: %w", err)
}
defer func() {
logger.Info(ctx, "Shutting down child processes")
if err := children.Shutdown(); err != nil {
logger.Error(ctx, "Failed to shut down children cleanly, there may be zombie children leftover", err)
}
}()
logger.Info(ctx, "Applying garage layout")
if err := daemon.GarageApplyLayout(
ctx, logger, daemonConfig, hostBootstrap,
); err != nil {
return fmt.Errorf("applying garage layout: %w", err)
}
logger.Info(ctx, "initializing garage shared global bucket")
garageGlobalBucketCreds, err := garageInitializeGlobalBucket(
ctx, logger, hostBootstrap, daemonConfig,
)
if cErr := (garage.AdminClientError{}); errors.As(err, &cErr) && cErr.StatusCode == 409 {
return fmt.Errorf("shared global bucket has already been created, are the storage allocations from a previously initialized isle being used?")
} else if err != nil {
return fmt.Errorf("initializing garage shared global bucket: %w", err)
}
hostBootstrap.Garage.GlobalBucketS3APICredentials = garageGlobalBucketCreds
// rewrite the bootstrap now that the global bucket creds have been
// added to it.
if err := writeBootstrapToStateDir(hostBootstrap); err != nil {
return fmt.Errorf("writing bootstrap file: %w", err)
}
logger.Info(ctx, "Network initialized successfully, writing admin.json to stdout")
adm := admin.Admin{
CreationParams: adminCreationParams,
}
adm.Nebula.CACredentials = nebulaCACreds
adm.Garage.RPCSecret = hostBootstrap.Garage.RPCSecret
adm.Garage.GlobalBucketS3APICredentials = hostBootstrap.Garage.GlobalBucketS3APICredentials
if err := adm.WriteTo(os.Stdout); err != nil {
return fmt.Errorf("writing admin.json to stdout")
}
return nil
},
}
var subCmdAdminCreateBootstrap = subCmd{ var subCmdAdminCreateBootstrap = subCmd{
name: "create-bootstrap", name: "create-bootstrap",
descr: "Creates a new bootstrap.json file for a particular host and writes it to stdout", descr: "Creates a new bootstrap.json file for a particular host and writes it to stdout",
@ -392,7 +201,6 @@ var subCmdAdmin = subCmd{
descr: "Sub-commands which only admins can run", descr: "Sub-commands which only admins can run",
do: func(subCmdCtx subCmdCtx) error { do: func(subCmdCtx subCmdCtx) error {
return subCmdCtx.doSubCmd( return subCmdCtx.doSubCmd(
subCmdAdminCreateNetwork,
subCmdAdminCreateBootstrap, subCmdAdminCreateBootstrap,
subCmdAdminCreateNebulaCert, subCmdAdminCreateNebulaCert,
) )

View File

@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"isle/bootstrap" "isle/bootstrap"
"os"
"path/filepath"
) )
func loadHostBootstrap() (bootstrap.Bootstrap, error) { func loadHostBootstrap() (bootstrap.Bootstrap, error) {
@ -26,22 +24,3 @@ func loadHostBootstrap() (bootstrap.Bootstrap, error) {
return hostBootstrap, nil return hostBootstrap, nil
} }
func writeBootstrapToStateDir(hostBootstrap bootstrap.Bootstrap) error {
path := bootstrap.StateDirPath(daemonEnvVars.StateDirPath)
dirPath := filepath.Dir(path)
if err := os.MkdirAll(dirPath, 0700); err != nil {
return fmt.Errorf("creating directory %q: %w", dirPath, err)
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating file %q: %w", path, err)
}
defer f.Close()
return hostBootstrap.WriteTo(f)
}

View File

@ -4,10 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"isle/bootstrap"
"isle/daemon" "isle/daemon"
"isle/daemon/jsonrpc2" "isle/daemon/jsonrpc2"
"isle/garage/garagesrv"
"net" "net"
"net/http" "net/http"
@ -15,50 +13,6 @@ import (
"dev.mediocregopher.com/mediocre-go-lib.git/mlog" "dev.mediocregopher.com/mediocre-go-lib.git/mlog"
) )
func coalesceDaemonConfigAndBootstrap(
hostBootstrap bootstrap.Bootstrap, daemonConfig daemon.Config,
) (
bootstrap.Bootstrap, error,
) {
host := bootstrap.Host{
HostAssigned: hostBootstrap.HostAssigned,
HostConfigured: bootstrap.HostConfigured{
Nebula: bootstrap.NebulaHost{
PublicAddr: daemonConfig.VPN.PublicAddr,
},
},
}
if allocs := daemonConfig.Storage.Allocations; len(allocs) > 0 {
for i, alloc := range allocs {
id, rpcPort, err := garagesrv.InitAlloc(alloc.MetaPath, alloc.RPCPort)
if err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf(
"initializing alloc at %q: %w", alloc.MetaPath, err,
)
}
host.Garage.Instances = append(host.Garage.Instances, bootstrap.GarageHostInstance{
ID: id,
RPCPort: rpcPort,
S3APIPort: alloc.S3APIPort,
})
allocs[i].RPCPort = rpcPort
}
}
hostBootstrap.Hosts[host.Name] = host
if err := writeBootstrapToStateDir(hostBootstrap); err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf("writing bootstrap file: %w", err)
}
return hostBootstrap, nil
}
const daemonHTTPRPCPath = "/rpc/v0.json" const daemonHTTPRPCPath = "/rpc/v0.json"
func newHTTPServer( func newHTTPServer(

View File

@ -1,50 +0,0 @@
package main
import (
"context"
"fmt"
"isle/bootstrap"
"isle/daemon"
"isle/garage"
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
)
func garageInitializeGlobalBucket(
ctx context.Context,
logger *mlog.Logger,
hostBootstrap bootstrap.Bootstrap,
daemonConfig daemon.Config,
) (
garage.S3APICredentials, error,
) {
adminClient := daemon.NewGarageAdminClient(
logger, daemonConfig, hostBootstrap,
)
creds, err := adminClient.CreateS3APICredentials(
ctx, garage.GlobalBucketS3APICredentialsName,
)
if err != nil {
return creds, fmt.Errorf("creating global bucket credentials: %w", err)
}
bucketID, err := adminClient.CreateBucket(ctx, garage.GlobalBucket)
if err != nil {
return creds, fmt.Errorf("creating global bucket: %w", err)
}
if err := adminClient.GrantBucketPermissions(
ctx,
bucketID,
creds.ID,
garage.BucketPermissionRead,
garage.BucketPermissionWrite,
); err != nil {
return creds, fmt.Errorf(
"granting permissions to shared global bucket key: %w", err,
)
}
return creds, nil
}

View File

@ -3,9 +3,70 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"isle/admin"
"isle/bootstrap" "isle/bootstrap"
"isle/daemon"
"os"
) )
var subCmdNetworkCreate = subCmd{
name: "create",
descr: "Create's a new network, with this host being the first host in that network. The resulting admin.json is output to stdout.",
do: func(subCmdCtx subCmdCtx) error {
var (
ctx = subCmdCtx.ctx
flags = subCmdCtx.flagSet(false)
req daemon.CreateNetworkRequest
)
flags.StringVarP(
&req.Name, "name", "n", "",
"Human-readable name to identify the network as.",
)
flags.StringVarP(
&req.Domain, "domain", "d", "",
"Domain name that should be used as the root domain in the network.",
)
flags.StringVarP(
&req.IPNet, "ip-net", "i", "",
`IP+prefix (e.g. "10.10.0.1/16") which denotes the IP of this`+
` host, which will be the first host in the network, and the`+
` range of IPs which other hosts in the network can be`+
` assigned`,
)
flags.StringVarP(
&req.HostName, "hostname", "h", "",
"Name of this host, which will be the first host in the network",
)
if err := flags.Parse(subCmdCtx.args); err != nil {
return fmt.Errorf("parsing flags: %w", err)
}
if req.Name == "" ||
req.Domain == "" ||
req.IPNet == "" ||
req.HostName == "" {
return errors.New("--name, --domain, --ip-net, and --hostname are required")
}
var adm admin.Admin
err := subCmdCtx.daemonRCPClient.Call(ctx, &adm, "CreateNetwork", req)
if err != nil {
return fmt.Errorf("creating network: %w", err)
}
if err := adm.WriteTo(os.Stdout); err != nil {
return fmt.Errorf("writing admin.json to stdout")
}
return nil
},
}
var subCmdNetworkJoin = subCmd{ var subCmdNetworkJoin = subCmd{
name: "join", name: "join",
descr: "Joins this host to an existing network", descr: "Joins this host to an existing network",
@ -45,6 +106,7 @@ var subCmdNetwork = subCmd{
descr: "Sub-commands related to network membership", descr: "Sub-commands related to network membership",
do: func(subCmdCtx subCmdCtx) error { do: func(subCmdCtx subCmdCtx) error {
return subCmdCtx.doSubCmd( return subCmdCtx.doSubCmd(
subCmdNetworkCreate,
subCmdNetworkJoin, subCmdNetworkJoin,
) )
}, },

View File

@ -349,24 +349,37 @@ func (d *daemon) postInit(ctx context.Context) bool {
// TODO this is pretty hacky, but there doesn't seem to be a better way to // TODO this is pretty hacky, but there doesn't seem to be a better way to
// manage it at the moment. // manage it at the moment.
if d.currBootstrap.Garage.GlobalBucketS3APICredentials == (garage.S3APICredentials{}) { if d.currBootstrap.Garage.GlobalBucketS3APICredentials == (garage.S3APICredentials{}) {
var garageGlobalBucketCreds garage.S3APICredentials currBootstrap := d.currBootstrap
if !until( if !until(
ctx, ctx,
d.logger, d.logger,
"Initializing garage shared global bucket", "Initializing garage shared global bucket",
func(ctx context.Context) error { func(ctx context.Context) error {
var err error garageGlobalBucketCreds, err := garageInitializeGlobalBucket(
garageGlobalBucketCreds, err = garageInitializeGlobalBucket(
ctx, d.logger, d.daemonConfig, d.currBootstrap, ctx, d.logger, d.daemonConfig, d.currBootstrap,
) )
return err if err != nil {
return fmt.Errorf("initializing global bucket: %w", err)
}
currBootstrap.Garage.GlobalBucketS3APICredentials = garageGlobalBucketCreds
d.logger.Info(ctx, "Writing bootstrap to state directory")
err = writeBootstrapToStateDir(
d.opts.EnvVars.StateDirPath, currBootstrap,
)
if err != nil {
return fmt.Errorf("writing bootstrap to state dir: %w", err)
}
return nil
}, },
) { ) {
return false return false
} }
d.l.Lock() d.l.Lock()
d.currBootstrap.Garage.GlobalBucketS3APICredentials = garageGlobalBucketCreds d.currBootstrap = currBootstrap
d.l.Unlock() d.l.Unlock()
} }

View File

@ -4,17 +4,13 @@ import (
"cmp" "cmp"
"context" "context"
"fmt" "fmt"
"isle/admin"
"isle/bootstrap" "isle/bootstrap"
"slices" "slices"
"golang.org/x/exp/maps" "golang.org/x/exp/maps"
) )
// GetHostsResult wraps the results from the GetHosts RPC method.
type GetHostsResult struct {
Hosts []bootstrap.Host
}
// RPC exposes all RPC methods which are available to be called over the RPC // RPC exposes all RPC methods which are available to be called over the RPC
// interface. // interface.
type RPC struct { type RPC struct {
@ -26,6 +22,35 @@ func NewRPC(daemon Daemon) *RPC {
return &RPC{daemon} return &RPC{daemon}
} }
// CreateNetworkRequest contains the arguments to the CreateNetwork RPC method.
//
// All fields are required.
type CreateNetworkRequest struct {
// Human-readable name of the network.
Name string
// Primary domain name that network services are served under.
Domain string
// An IP + subnet mask which represents both the IP of this first host in
// the network, as well as the overall range of possible IPs in the network.
IPNet string
// The name of this first host in the network.
HostName string
}
// CreateNetwork passes through to the Daemon method of the same name.
func (r *RPC) CreateNetwork(
ctx context.Context, req CreateNetworkRequest,
) (
admin.Admin, error,
) {
return r.daemon.CreateNetwork(
ctx, req.Name, req.Domain, req.IPNet, req.HostName,
)
}
// JoinNetwork passes through to the Daemon method of the same name. // JoinNetwork passes through to the Daemon method of the same name.
func (r *RPC) JoinNetwork( func (r *RPC) JoinNetwork(
ctx context.Context, req bootstrap.Bootstrap, ctx context.Context, req bootstrap.Bootstrap,
@ -35,6 +60,11 @@ func (r *RPC) JoinNetwork(
return struct{}{}, r.daemon.JoinNetwork(ctx, req) return struct{}{}, r.daemon.JoinNetwork(ctx, req)
} }
// GetHostsResult wraps the results from the GetHosts RPC method.
type GetHostsResult struct {
Hosts []bootstrap.Host
}
// GetHosts returns all hosts known to the network, sorted by their name. // GetHosts returns all hosts known to the network, sorted by their name.
func (r *RPC) GetHosts( func (r *RPC) GetHosts(
ctx context.Context, req struct{}, ctx context.Context, req struct{},

View File

@ -49,22 +49,21 @@ if [ ! -d "$XDG_RUNTIME_DIR/isle" ]; then
capacity: 1 capacity: 1
EOF EOF
isle daemon -l debug --config-path daemon.yml >daemon.log 2>&1 &
pid="$!"
$SHELL "$UTILS/register-cleanup.sh" "$pid" "1-data-1-empty-node-network/primus"
echo "Waiting for primus daemon (process $pid) to start"
while ! [ -e "$ISLE_DAEMON_HTTP_SOCKET_PATH" ]; do sleep 1; done
echo "Creating 1-data-1-empty network" echo "Creating 1-data-1-empty network"
isle admin create-network \ isle network create \
--config-path daemon.yml \
--domain shared.test \ --domain shared.test \
--hostname primus \ --hostname primus \
--ip-net "$current_ip/24" \ --ip-net "$current_ip/24" \
--name "testing" \ --name "testing" \
> admin.json > admin.json
isle daemon -l debug --config-path daemon.yml >daemon.log 2>&1 &
pid="$!"
$SHELL "$UTILS/register-cleanup.sh" "$pid" "1-data-1-empty-node-network/primus"
echo "Waiting for primus daemon (process $pid) to initialize"
while ! isle hosts list >/dev/null; do sleep 1; done
echo "Creating secondus bootstrap" echo "Creating secondus bootstrap"
isle admin create-bootstrap \ isle admin create-bootstrap \
--admin-path admin.json \ --admin-path admin.json \