diff --git a/go.mod b/go.mod index 8b69a868294..fff86f1667d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.27.7 github.com/aws/aws-sdk-go-v2/credentials v1.17.7 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.9 - github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.23.4 github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0 github.com/aws/aws-sdk-go-v2/service/sts v1.28.4 github.com/bmatcuk/doublestar/v4 v4.6.1 @@ -40,7 +39,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 github.com/zealic/go2node v0.1.0 go.jetpack.io/envsec v0.0.16-0.20240329013200-4174c0acdb00 - go.jetpack.io/pkg v0.0.0-20240404001923-7b42192bf9a5 + go.jetpack.io/pkg v0.0.0-20240405214046-034a6476e201 go.jetpack.io/typeid v1.0.0 golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 golang.org/x/mod v0.16.0 diff --git a/go.sum b/go.sum index 1774fcc1a76..1fe36d1f317 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4 h1:SIkD6T4zGQ+1YIit22wi37CGNkrE7mXV1vNA5VpI3TI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.4/go.mod h1:XfeqbsG0HNedNs0GT+ju4Bs+pFAwsrlzcRdMvdNVf5s= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.23.4 h1:KuN2GQBLzac3PdhsVBt7n11jKfRsXg0OZSuuizF+yNw= -github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.23.4/go.mod h1:OnFArLhSkVvZjmlx3wiYir/O44gpEerCXPJbK+LQBSE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 h1:NkHCgg0Ck86c5PTOzBZ0JRccI51suJDg5lgFtxBu1ek= @@ -365,8 +363,8 @@ github.com/zealic/go2node v0.1.0 h1:ofxpve08cmLJBwFdI0lPCk9jfwGWOSD+s6216x0oAaA= github.com/zealic/go2node v0.1.0/go.mod h1:GrkFr+HctXwP7vzcU9RsgtAeJjTQ6Ud0IPCQAqpTfBg= go.jetpack.io/envsec v0.0.16-0.20240329013200-4174c0acdb00 h1:Kb+OlWOntAq+1nF+01ntqnQEqSJkFmLLS0RX5sl5zak= go.jetpack.io/envsec v0.0.16-0.20240329013200-4174c0acdb00/go.mod h1:dVG2n8fBAGpQczW8yk/6wuXb9uEhzaJF7wGXkGLRRCU= -go.jetpack.io/pkg v0.0.0-20240404001923-7b42192bf9a5 h1:uFFlceGNlxqrKA/1umrBvNLTTBNkBU306Uqq1O27agM= -go.jetpack.io/pkg v0.0.0-20240404001923-7b42192bf9a5/go.mod h1:gtmpVShXMEcZPBFZHswB3oCPYXobeR41b9CMybAjQYw= +go.jetpack.io/pkg v0.0.0-20240405214046-034a6476e201 h1:59icpq6Y6uqnyG+IVijjGvLnL4U3syjFkq2ILnsC30Q= +go.jetpack.io/pkg v0.0.0-20240405214046-034a6476e201/go.mod h1:gtmpVShXMEcZPBFZHswB3oCPYXobeR41b9CMybAjQYw= go.jetpack.io/typeid v1.0.0 h1:8gQ+iYGdyiQ0Pr40ydSB/PzMOIwlXX5DTojp1CBeSPQ= go.jetpack.io/typeid v1.0.0/go.mod h1:+UPEaECUgFxgAjFPn5Yf9eO/3ft/3xZ98Eahv9JW/GQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/internal/boxcli/cache.go b/internal/boxcli/cache.go index d2c61d92693..733ab5ea187 100644 --- a/internal/boxcli/cache.go +++ b/internal/boxcli/cache.go @@ -5,6 +5,7 @@ package boxcli import ( "encoding/json" + "os/user" "github.com/MakeNowJust/heredoc/v2" "github.com/pkg/errors" @@ -33,9 +34,9 @@ func cacheCmd() *cobra.Command { Short: "upload specified or nix packages in current project to cache", Long: heredoc.Doc(` Upload specified nix installable or nix packages in current project to cache. - If [installable] is provided, only that installable will be uploaded. + If [installable] is provided, only that installable will be uploaded. Otherwise, all packages in the project will be uploaded. - To upload to specific cache, use --to flag. Otherwise, a cache from + To upload to specific cache, use --to flag. Otherwise, a cache from the cache provider will be used, if available. `), Args: cobra.MaximumNArgs(1), @@ -61,12 +62,32 @@ func cacheCmd() *cobra.Command { &flags.to, "to", "", "URI of the cache to copy to") cacheCommand.AddCommand(uploadCommand) + cacheCommand.AddCommand(cacheConfigureCmd()) cacheCommand.AddCommand(cacheCredentialsCmd()) cacheCommand.Hidden = true return cacheCommand } +func cacheConfigureCmd() *cobra.Command { + username := "" + cmd := &cobra.Command{ + Use: "configure", + Short: "Configure Nix to use the Devbox cache as a substituter", + Hidden: true, + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if username == "" { + u, _ := user.Current() + username = u.Username + } + return nixcache.Get().ConfigureAWS(cmd.Context(), username) + }, + } + cmd.Flags().StringVar(&username, "user", "", "") + return cmd +} + func cacheCredentialsCmd() *cobra.Command { return &cobra.Command{ Use: "credentials", @@ -74,28 +95,16 @@ func cacheCredentialsCmd() *cobra.Command { Hidden: true, Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := nixcache.Get().Config(cmd.Context()) + creds, err := nixcache.Get().Credentials(cmd.Context()) if err != nil { return err } - - creds := struct { - Version int `json:"Version"` - AccessKeyID string `json:"AccessKeyId"` - SecretAccessKey string `json:"SecretAccessKey"` - SessionToken string `json:"SessionToken"` - }{ - Version: 1, - AccessKeyID: *cfg.Credentials.AccessKeyId, - SecretAccessKey: *cfg.Credentials.SecretKey, - SessionToken: *cfg.Credentials.SessionToken, - } out, err := json.Marshal(creds) if err != nil { return err } - _, _ = cmd.OutOrStdout().Write(out) - return nil + _, err = cmd.OutOrStdout().Write(out) + return err }, } } diff --git a/internal/devbox/cache.go b/internal/devbox/cache.go index 4ba42b38acd..6dce75f697f 100644 --- a/internal/devbox/cache.go +++ b/internal/devbox/cache.go @@ -4,6 +4,7 @@ import ( "context" "io" + "go.jetpack.io/devbox/internal/boxcli/usererr" "go.jetpack.io/devbox/internal/devbox/providers/nixcache" "go.jetpack.io/devbox/internal/nix" ) @@ -12,22 +13,26 @@ func (d *Devbox) UploadProjectToCache( ctx context.Context, cacheURI string, ) error { - var err error - cacheConfig := nixcache.NixCacheConfig{URI: cacheURI} - if cacheConfig.URI == "" { - cacheConfig, err = d.providers.NixCache.Config(ctx) + if cacheURI == "" { + var err error + cacheURI, err = d.providers.NixCache.URI(ctx) if err != nil { return err } + if cacheURI == "" { + return usererr.New("Your account's organization doesn't have a Nix cache.") + } + } + + creds, err := d.providers.NixCache.Credentials(ctx) + if err != nil { + return err } profilePath, err := d.profilePath() if err != nil { return err } - - return nix.CopyInstallableToCache( - ctx, - d.stderr, cacheConfig.URI, profilePath, cacheConfig.CredentialsEnvVars()) + return nix.CopyInstallableToCache(ctx, d.stderr, cacheURI, profilePath, creds.Env()) } func UploadInstallableToCache( @@ -35,15 +40,20 @@ func UploadInstallableToCache( stderr io.Writer, cacheURI, installable string, ) error { - var err error - cacheConfig := nixcache.NixCacheConfig{URI: cacheURI} - if cacheConfig.URI == "" { - cacheConfig, err = nixcache.Get().Config(ctx) + if cacheURI == "" { + var err error + cacheURI, err = nixcache.Get().URI(ctx) if err != nil { return err } + if cacheURI == "" { + return usererr.New("Your account's organization doesn't have a Nix cache.") + } + } + + creds, err := nixcache.Get().Credentials(ctx) + if err != nil { + return err } - return nix.CopyInstallableToCache( - ctx, - stderr, cacheConfig.URI, installable, cacheConfig.CredentialsEnvVars()) + return nix.CopyInstallableToCache(ctx, stderr, cacheURI, installable, creds.Env()) } diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index 67890634d89..289d191169a 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -445,19 +445,19 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context, mode installMode flags = append(flags, "--refresh") } - nixCacheConfig, err := d.providers.NixCache.Config(ctx) - if err != nil { - return err + args := &nix.BuildArgs{ + AllowInsecure: pkg.HasAllowInsecure(), + Flags: flags, + Writer: d.stderr, } - - for _, installable := range installables { - args := &nix.BuildArgs{ - AllowInsecure: pkg.HasAllowInsecure(), - Env: nixCacheConfig.CredentialsEnvVars(), - ExtraSubstituter: nixCacheConfig.URI, - Flags: flags, - Writer: d.stderr, + args.ExtraSubstituter, err = d.providers.NixCache.URI(ctx) + if err == nil { + creds, err := d.providers.NixCache.Credentials(ctx) + if err == nil { + args.Env = creds.Env() } + } + for _, installable := range installables { err = nix.Build(ctx, args, installable) if err != nil { fmt.Fprintf(d.stderr, "%s: ", stepMsg) diff --git a/internal/devbox/providers/nixcache/nixcache.go b/internal/devbox/providers/nixcache/nixcache.go index 1d39e151f19..550bc165287 100644 --- a/internal/devbox/providers/nixcache/nixcache.go +++ b/internal/devbox/providers/nixcache/nixcache.go @@ -2,19 +2,24 @@ package nixcache import ( "context" - "errors" + "fmt" + "io/fs" "os" + "os/exec" + "os/user" + "path/filepath" "time" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/cognitoidentity/types" + "github.com/AlecAivazis/survey/v2" "go.jetpack.io/devbox/internal/build" "go.jetpack.io/devbox/internal/devbox/providers/identity" + "go.jetpack.io/devbox/internal/envir" + "go.jetpack.io/devbox/internal/fileutil" "go.jetpack.io/devbox/internal/nix" + "go.jetpack.io/devbox/internal/redact" "go.jetpack.io/devbox/internal/ux" "go.jetpack.io/pkg/api" nixv1alpha1 "go.jetpack.io/pkg/api/gen/priv/nix/v1alpha1" - "go.jetpack.io/pkg/auth" "go.jetpack.io/pkg/filecache" ) @@ -22,71 +27,186 @@ type Provider struct{} var singleton *Provider = &Provider{} -type NixCacheConfig struct { - URI string - Credentials types.Credentials +func Get() *Provider { + return singleton } -func (n NixCacheConfig) CredentialsEnvVars() []string { - env := []string{} - if n.Credentials.AccessKeyId != nil { - env = append(env, "AWS_ACCESS_KEY_ID="+*n.Credentials.AccessKeyId) +func (p *Provider) ConfigureAWS(ctx context.Context, username string) error { + rootConfig, err := p.rootAWSConfigPath() + if err != nil { + return err } - if n.Credentials.SecretKey != nil { - env = append(env, "AWS_SECRET_ACCESS_KEY="+*n.Credentials.SecretKey) + if fileutil.Exists(rootConfig) { + // Already configured. + return nil } - if n.Credentials.SessionToken != nil { - env = append(env, "AWS_SESSION_TOKEN="+*n.Credentials.SessionToken) + + if os.Getuid() == 0 { + err := p.configureRoot(username) + if err != nil { + return redact.Errorf("update ~root/.aws/config with devbox credentials: %s", err) + } + return nil } - return env + + _, err = nix.DaemonVersion(ctx) + if err == nil { + // It looks like this is a multi-user install running a Nix + // daemon, so we need to configure AWS S3 authentication for the + // root user. + if err := p.sudoConfigureRoot(ctx, username); err != nil { + return err + } + } + return nil } -func Get() *Provider { - return singleton +func (p *Provider) rootAWSConfigPath() (string, error) { + u, err := user.LookupId("0") + if err != nil { + return "", redact.Errorf("lookup root user: %s", err) + } + if u.HomeDir == "" { + return "", redact.Errorf("empty root user home directory: %s", u.Username, err) + } + return filepath.Join(u.HomeDir, ".aws", "config"), nil } -// Config returns the URI or the nix bin cache and AWS credentials if available. -// Nix calls the URI a substituter. -// A substituter is a bin cache URI that nix can use to fetch pre-built -// binaries from. -func (p *Provider) Config(ctx context.Context) (NixCacheConfig, error) { - token, err := identity.Get().GenSession(ctx) - - if errors.Is(err, auth.ErrNotLoggedIn) { - // DEVBOX_NIX_BINCACHE_URI seems like a friendlier name than "substituter" - return NixCacheConfig{ - URI: os.Getenv("DEVBOX_NIX_BINCACHE_URI"), - }, nil - } else if err != nil { - return NixCacheConfig{}, err - } - - apiClient := api.NewClient(ctx, build.JetpackAPIHost(), token) - cache := filecache.New[*nixv1alpha1.GetBinCacheResponse]("devbox/credentials") - binCacheResponse, err := cache.GetOrSetWithTime( - "aws-nix-bin-cache", - func() (*nixv1alpha1.GetBinCacheResponse, time.Time, error) { - r, err := apiClient.GetBinCache(ctx) - if err != nil || r.GetNixBinCacheUri() == "" { - return nil, time.Time{}, err - } - return r, r.GetNixBinCacheCredentials().Expiration.AsTime(), nil - }, - ) +func (p *Provider) configureRoot(username string) error { + exe := p.executable() + if exe == "" { + return redact.Errorf("get path to current devbox executable") + } + sudo, err := exec.LookPath("sudo") + if err != nil { + return redact.Errorf("get path to sudo executable: %s", err) + } + path, err := p.rootAWSConfigPath() if err != nil { - return NixCacheConfig{}, err + return err } - checkIfUserCanAddSubstituter(ctx) + // Rename the .aws directory in case it already exists. We should + // improve this to be more careful with existing ~root/.aws/configs, but + // this seems rare enough that it should be okay for the initial + // implementation. + dir := filepath.Dir(path) + _ = os.Rename(dir, dir+".bak") // ignore errors for non-existent dir + _ = os.Mkdir(dir, 0o755) // ignore errors for dir exists (don't os.MkdirAll the home directory) + + config, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fs.FileMode(0o644)) + if err != nil { + return err + } + defer config.Close() + + // TODO(gcurtis): it would be nice to use a non-default profile if + // https://github.com/NixOS/nix/issues/5525 ever gets fixed. + _, err = fmt.Fprintf(config, `# This file was generated by Devbox. +# Any overwritten configs can be found in the .aws.bak directory. + +[default] +# sudo as the configured user so that their cached credential files have the +# correct ownership. +credential_process = %s -u %s -i %s cache credentials +`, sudo, username, exe) + if err != nil { + return err + } + return config.Close() +} + +func (p *Provider) sudoConfigureRoot(ctx context.Context, username string) error { + // TODO(gcurtis): save the user's response so that we don't pester them + // every time if it's a no. + prompt := &survey.Confirm{ + Message: "Devbox requires root to configure the Nix daemon to use your organization's private cache. Allow sudo?", + } + ok := false + if err := survey.AskOne(prompt, &ok); err != nil { + return err + } + if !ok { + return nil + } - return NixCacheConfig{ - URI: binCacheResponse.NixBinCacheUri, - Credentials: types.Credentials{ - AccessKeyId: aws.String(binCacheResponse.GetNixBinCacheCredentials().GetAccessKeyId()), - SecretKey: aws.String(binCacheResponse.GetNixBinCacheCredentials().GetSecretKey()), - SessionToken: aws.String(binCacheResponse.GetNixBinCacheCredentials().GetSessionToken()), - }, - }, nil + exe := p.executable() + if exe == "" { + return redact.Errorf("get path to current devbox executable") + } + + cmd := exec.CommandContext(ctx, "sudo", exe, "cache", "configure", "--user", username) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to relaunch with sudo: %w", err) + } + return nil +} + +func (*Provider) executable() string { + if exe := os.Getenv(envir.LauncherPath); exe != "" { + return exe + } + if exe, err := os.Executable(); err == nil { + return exe + } + return "" +} + +// Credentials fetches short-lived credentials that grant access to the user's +// private cache. +func (p *Provider) Credentials(ctx context.Context) (AWSCredentials, error) { + cache := filecache.New[AWSCredentials]("devbox/providers/nixcache") + creds, err := cache.GetOrSetWithTime("credentials", func() (AWSCredentials, time.Time, error) { + token, err := identity.Get().GenSession(ctx) + if err != nil { + return AWSCredentials{}, time.Time{}, err + } + client := api.NewClient(ctx, build.JetpackAPIHost(), token) + creds, err := client.GetAWSCredentials(ctx) + if err != nil { + return AWSCredentials{}, time.Time{}, err + } + exp := time.Time{} + if t := creds.GetExpiration(); t != nil { + exp = t.AsTime() + } + return newAWSCredentials(creds), exp, nil + }) + if err != nil { + return AWSCredentials{}, redact.Errorf("nixcache: get credentials: %w", redact.Safe(err)) + } + return creds, nil +} + +// URI queries the Jetify API for the URI that points to user's private cache. +// If their account doesn't have access to a cache, it returns an empty string +// and a nil error. +func (p *Provider) URI(ctx context.Context) (string, error) { + cache := filecache.New[string]("devbox/providers/nixcache") + uri, err := cache.GetOrSet("uri", func() (string, time.Duration, error) { + token, err := identity.Get().GenSession(ctx) + if err != nil { + return "", 0, err + } + client := api.NewClient(ctx, build.JetpackAPIHost(), token) + resp, err := client.GetBinCache(ctx) + if err != nil { + return "", 0, redact.Errorf("nixcache: get uri: %w", redact.Safe(err)) + } + + // TODO(gcurtis): do a better job of invalidating the URI after + // logout or after a Nix command fails to query the cache. + return resp.GetNixBinCacheUri(), 24 * time.Hour, nil + }) + if err != nil { + return "", redact.Errorf("nixcache: get uri: %w", redact.Safe(err)) + } + checkIfUserCanAddSubstituter(ctx) + return uri, nil } func checkIfUserCanAddSubstituter(ctx context.Context) { @@ -106,7 +226,41 @@ func checkIfUserCanAddSubstituter(ctx context.Context) { os.Stderr, "In order to use a custom nix cache you must be a trusted user. Please "+ "add your username to nix.conf (usually located at /etc/nix/nix.conf)"+ - " and restart the nix daemon.", + " and restart the nix daemon.\n", ) } } + +// AWSCredentials are short-lived credentials that grant access to a private Nix +// cache in S3. It marshals to JSON per the schema described in +// `aws help config-vars` under "Sourcing Credentials From External Processes". +type AWSCredentials struct { + // Version must always be 1. + Version int `json:"Version"` + AccessKeyID string `json:"AccessKeyId"` + SecretAccessKey string `json:"SecretAccessKey"` + SessionToken string `json:"SessionToken"` + Expiration time.Time `json:"Expiration"` +} + +func newAWSCredentials(proto *nixv1alpha1.AWSCredentials) AWSCredentials { + creds := AWSCredentials{ + Version: 1, + AccessKeyID: proto.AccessKeyId, + SecretAccessKey: proto.SecretKey, + SessionToken: proto.SessionToken, + } + if proto.Expiration != nil { + creds.Expiration = proto.Expiration.AsTime() + } + return creds +} + +// Env returns the credentials as a slice of environment variables. +func (a AWSCredentials) Env() []string { + return []string{ + "AWS_ACCESS_KEY_ID=" + a.AccessKeyID, + "AWS_SECRET_ACCESS_KEY=" + a.SecretAccessKey, + "AWS_SESSION_TOKEN=" + a.SessionToken, + } +}