41e0b56617
This required a lot of re-implementation of how garage gets interacted with, including updating cluster layout using the admin API and initialization of the global bucket key.
427 lines
11 KiB
Go
427 lines
11 KiB
Go
package entrypoint
|
|
|
|
import (
|
|
"context"
|
|
"cryptic-net/admin"
|
|
"cryptic-net/bootstrap"
|
|
"cryptic-net/garage"
|
|
"cryptic-net/nebula"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/cryptic-io/pmux/pmuxlib"
|
|
)
|
|
|
|
func randStr(l int) string {
|
|
b := make([]byte, l)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func readAdmin(path string) (admin.Admin, error) {
|
|
|
|
if path == "-" {
|
|
|
|
adm, err := admin.FromReader(os.Stdin)
|
|
if err != nil {
|
|
return admin.Admin{}, fmt.Errorf("parsing admin.tgz from stdin: %w", err)
|
|
}
|
|
|
|
return adm, nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return admin.Admin{}, fmt.Errorf("opening file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
return admin.FromReader(f)
|
|
}
|
|
|
|
func garageInitializeGlobalBucket(
|
|
ctx context.Context,
|
|
adminClient *garage.AdminClient,
|
|
globalBucketCreds garage.S3APICredentials,
|
|
) error {
|
|
|
|
// first attempt to import the key
|
|
err := adminClient.Do(ctx, nil, "POST", "/v0/key/import", map[string]string{
|
|
"accessKeyId": globalBucketCreds.ID,
|
|
"secretAccessKey": globalBucketCreds.Secret,
|
|
"name": "shared-global-bucket-key",
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("importing global bucket key into garage: %w", err)
|
|
}
|
|
|
|
// create global bucket
|
|
err = adminClient.Do(ctx, nil, "POST", "/v0/bucket", map[string]string{
|
|
"globalAlias": garage.GlobalBucket,
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("creating global bucket: %w", err)
|
|
}
|
|
|
|
// retrieve newly created bucket's id
|
|
var getBucketRes struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
err = adminClient.Do(
|
|
ctx, &getBucketRes,
|
|
"GET", "/v0/bucket?globalAlias="+garage.GlobalBucket, nil,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("fetching global bucket id: %w", err)
|
|
}
|
|
|
|
// allow shared global bucket key to perform all operations
|
|
err = adminClient.Do(ctx, nil, "POST", "/v0/bucket/allow", map[string]interface{}{
|
|
"bucketId": getBucketRes.ID,
|
|
"accessKeyId": globalBucketCreds.ID,
|
|
"permissions": map[string]bool{
|
|
"read": true,
|
|
"write": true,
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("granting permissions to shared global bucket key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var subCmdAdminCreateNetwork = subCmd{
|
|
name: "create-network",
|
|
descr: "Creates a new cryptic-net network, outputting the resulting admin.tgz to stdout",
|
|
do: func(subCmdCtx subCmdCtx) error {
|
|
|
|
flags := subCmdCtx.flagSet(false)
|
|
|
|
daemonYmlPath := 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.",
|
|
)
|
|
|
|
domain := flags.StringP(
|
|
"domain", "d", "",
|
|
"Domain name that should be used as the root domain in the network.",
|
|
)
|
|
|
|
subnetStr := flags.StringP(
|
|
"subnet", "s", "",
|
|
"CIDR which denotes the subnet that IPs hosts on the network can be assigned.",
|
|
)
|
|
|
|
hostName := flags.StringP(
|
|
"name", "n", "",
|
|
"Name of the 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)
|
|
}
|
|
|
|
env := subCmdCtx.env
|
|
|
|
if *dumpConfig {
|
|
return writeBuiltinDaemonYml(env, os.Stdout)
|
|
}
|
|
|
|
if *domain == "" || *subnetStr == "" || *hostName == "" {
|
|
return errors.New("--domain, --subnet, and --name are required")
|
|
}
|
|
|
|
*domain = strings.TrimRight(strings.TrimLeft(*domain, "."), ".")
|
|
|
|
ip, subnet, err := net.ParseCIDR(*subnetStr)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing %q as a CIDR: %w", *subnetStr, err)
|
|
}
|
|
|
|
if err := validateHostName(*hostName); err != nil {
|
|
return fmt.Errorf("invalid hostname %q: %w", *hostName, err)
|
|
}
|
|
|
|
adminCreationParams := admin.CreationParams{
|
|
ID: randStr(32),
|
|
Domain: *domain,
|
|
}
|
|
|
|
{
|
|
runtimeDirPath := env.RuntimeDirPath
|
|
|
|
fmt.Fprintf(os.Stderr, "will use runtime directory %q for temporary state\n", runtimeDirPath)
|
|
|
|
if err := os.MkdirAll(runtimeDirPath, 0700); err != nil {
|
|
return fmt.Errorf("creating directory %q: %w", runtimeDirPath, err)
|
|
}
|
|
|
|
defer func() {
|
|
fmt.Fprintf(os.Stderr, "cleaning up runtime directory %q\n", runtimeDirPath)
|
|
if err := os.RemoveAll(runtimeDirPath); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error removing temporary directory %q: %v", runtimeDirPath, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
if err := writeMergedDaemonYml(env, *daemonYmlPath); err != nil {
|
|
return fmt.Errorf("merging and writing daemon.yml file: %w", err)
|
|
}
|
|
|
|
daemon := env.ThisDaemon()
|
|
|
|
if len(daemon.Storage.Allocations) < 3 {
|
|
return fmt.Errorf("daemon.yml with at least 3 allocations was not provided")
|
|
}
|
|
|
|
nebulaCACert, err := nebula.NewCACert(*domain, subnet)
|
|
if err != nil {
|
|
return fmt.Errorf("creating nebula CA cert: %w", err)
|
|
}
|
|
|
|
nebulaHostCert, err := nebula.NewHostCert(nebulaCACert, *hostName, ip)
|
|
if err != nil {
|
|
return fmt.Errorf("creating nebula cert for host: %w", err)
|
|
}
|
|
|
|
env.Bootstrap = bootstrap.Bootstrap{
|
|
AdminCreationParams: adminCreationParams,
|
|
Hosts: map[string]bootstrap.Host{
|
|
*hostName: bootstrap.Host{
|
|
Name: *hostName,
|
|
Nebula: bootstrap.NebulaHost{
|
|
IP: ip.String(),
|
|
},
|
|
},
|
|
},
|
|
HostName: *hostName,
|
|
NebulaHostCert: nebulaHostCert,
|
|
GarageRPCSecret: randStr(32),
|
|
GarageGlobalBucketS3APICredentials: garage.NewS3APICredentials(),
|
|
}
|
|
|
|
// this will also write the bootstrap file
|
|
if err := mergeDaemonIntoBootstrap(env); err != nil {
|
|
return fmt.Errorf("merging daemon.yml into bootstrap data: %w", err)
|
|
}
|
|
|
|
// TODO this can be gotten rid of once nebula-entrypoint is rolled into
|
|
// daemon itself
|
|
for key, val := range env.ToMap() {
|
|
if err := os.Setenv(key, val); err != nil {
|
|
return fmt.Errorf("failed to set %q to %q: %w", key, val, err)
|
|
}
|
|
}
|
|
|
|
garageChildrenPmuxProcConfigs, err := garageChildrenPmuxProcConfigs(env)
|
|
if err != nil {
|
|
return fmt.Errorf("generating garage children configs: %w", err)
|
|
}
|
|
|
|
pmuxConfig := pmuxlib.Config{
|
|
Processes: append(
|
|
[]pmuxlib.ProcessConfig{
|
|
nebulaEntrypointPmuxProcConfig(),
|
|
},
|
|
garageChildrenPmuxProcConfigs...,
|
|
),
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(env.Context)
|
|
pmuxDoneCh := make(chan struct{})
|
|
|
|
log.Printf("starting child processes")
|
|
go func() {
|
|
pmuxlib.Run(ctx, pmuxConfig)
|
|
close(pmuxDoneCh)
|
|
}()
|
|
|
|
defer func() {
|
|
cancel()
|
|
log.Printf("waiting for child processes to exit")
|
|
<-pmuxDoneCh
|
|
}()
|
|
|
|
var garageAdminClient *garage.AdminClient
|
|
garageAdminClients := map[string]*garage.AdminClient{}
|
|
|
|
for _, alloc := range daemon.Storage.Allocations {
|
|
|
|
garageAdminAddr := net.JoinHostPort(ip.String(), strconv.Itoa(alloc.AdminPort))
|
|
|
|
garageAdminClient = garage.NewAdminClient(
|
|
garageAdminAddr,
|
|
env.Bootstrap.GarageAdminToken,
|
|
)
|
|
|
|
garageAdminClients[garageAdminAddr] = garageAdminClient
|
|
}
|
|
|
|
log.Printf("waiting for garage instances to come online")
|
|
for garageAdminAddr, garageAdminClient := range garageAdminClients {
|
|
if err := garageAdminClient.Wait(ctx); err != nil {
|
|
return fmt.Errorf("waiting for garage instance %q to start up: %w", garageAdminAddr, err)
|
|
}
|
|
}
|
|
|
|
log.Printf("applying initial garage layout")
|
|
err = garageApplyLayout(
|
|
ctx,
|
|
garageAdminClient,
|
|
*hostName, ip.String(),
|
|
daemon.Storage.Allocations,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("applying initial garage layout: %w", err)
|
|
}
|
|
|
|
log.Printf("initializing garage shared global bucket")
|
|
err = garageInitializeGlobalBucket(
|
|
ctx,
|
|
garageAdminClient,
|
|
env.Bootstrap.GarageGlobalBucketS3APICredentials,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("initializing garage shared global bucket: %w", err)
|
|
}
|
|
|
|
garageS3Client, err := env.Bootstrap.GlobalBucketS3APIClient()
|
|
if err != nil {
|
|
return fmt.Errorf("initializing garage shared global bucket client: %w", err)
|
|
}
|
|
|
|
log.Printf("writing data for this host into garage")
|
|
err = bootstrap.PutGarageBoostrapHost(ctx, garageS3Client, env.Bootstrap.ThisHost())
|
|
if err != nil {
|
|
return fmt.Errorf("putting host data into garage: %w", err)
|
|
}
|
|
|
|
log.Printf("cluster initialized successfully, writing admin.tgz to stdout")
|
|
|
|
err = admin.Admin{
|
|
CreationParams: adminCreationParams,
|
|
NebulaCACert: nebulaCACert,
|
|
GarageRPCSecret: env.Bootstrap.GarageRPCSecret,
|
|
GarageGlobalBucketS3APICredentials: env.Bootstrap.GarageGlobalBucketS3APICredentials,
|
|
GarageAdminBucketS3APICredentials: garage.NewS3APICredentials(),
|
|
}.WriteTo(os.Stdout)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("writing admin.tgz to stdout")
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var subCmdAdminMakeBootstrap = subCmd{
|
|
name: "make-bootstrap",
|
|
descr: "Creates a new bootstrap.tgz file for a particular host and writes it to stdout",
|
|
checkLock: true,
|
|
do: func(subCmdCtx subCmdCtx) error {
|
|
|
|
flags := subCmdCtx.flagSet(false)
|
|
|
|
name := flags.StringP(
|
|
"name", "n", "",
|
|
"Name of the host to generate bootstrap.tgz for",
|
|
)
|
|
|
|
adminPath := flags.StringP(
|
|
"admin-path", "a", "",
|
|
`Path to admin.tgz file. If the given path is "-" then stdin is used.`,
|
|
)
|
|
|
|
if err := flags.Parse(subCmdCtx.args); err != nil {
|
|
return fmt.Errorf("parsing flags: %w", err)
|
|
}
|
|
|
|
if *name == "" || *adminPath == "" {
|
|
return errors.New("--name and --admin-path are required")
|
|
}
|
|
|
|
env := subCmdCtx.env
|
|
|
|
adm, err := readAdmin(*adminPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading admin.tgz with --admin-path of %q: %w", *adminPath, err)
|
|
}
|
|
|
|
client, err := env.Bootstrap.GlobalBucketS3APIClient()
|
|
if err != nil {
|
|
return fmt.Errorf("creating client for global bucket: %w", err)
|
|
}
|
|
|
|
// NOTE this isn't _technically_ required, but if the `hosts add`
|
|
// command for this host has been run recently then it might not have
|
|
// made it into the bootstrap file yet, and so won't be in
|
|
// `env.Bootstrap`.
|
|
hosts, err := bootstrap.GetGarageBootstrapHosts(env.Context, client)
|
|
if err != nil {
|
|
return fmt.Errorf("retrieving host info from garage: %w", err)
|
|
}
|
|
|
|
host, ok := hosts[*name]
|
|
if !ok {
|
|
return fmt.Errorf("couldn't find host into for %q in garage, has `cryptic-net hosts add` been run yet?", *name)
|
|
}
|
|
|
|
ip := net.ParseIP(host.Nebula.IP)
|
|
if ip == nil {
|
|
return fmt.Errorf("invalid IP stored with host %q: %q", *name, host.Nebula.IP)
|
|
}
|
|
|
|
nebulaHostCert, err := nebula.NewHostCert(adm.NebulaCACert, host.Name, ip)
|
|
if err != nil {
|
|
return fmt.Errorf("creating new nebula host key/cert: %w", err)
|
|
}
|
|
|
|
newBootstrap := bootstrap.Bootstrap{
|
|
AdminCreationParams: adm.CreationParams,
|
|
|
|
Hosts: hosts,
|
|
HostName: *name,
|
|
|
|
NebulaHostCert: nebulaHostCert,
|
|
|
|
GarageRPCSecret: adm.GarageRPCSecret,
|
|
GarageAdminToken: randStr(32),
|
|
GarageGlobalBucketS3APICredentials: adm.GarageGlobalBucketS3APICredentials,
|
|
}
|
|
|
|
return newBootstrap.WriteTo(os.Stdout)
|
|
},
|
|
}
|
|
|
|
var subCmdAdmin = subCmd{
|
|
name: "admin",
|
|
descr: "Sub-commands which only admins can run",
|
|
do: func(subCmdCtx subCmdCtx) error {
|
|
return subCmdCtx.doSubCmd(
|
|
subCmdAdminMakeBootstrap,
|
|
)
|
|
},
|
|
}
|