From 82bd50e8f6bbfa020287c994a9c6b2d70c632801 Mon Sep 17 00:00:00 2001 From: Mike Landau Date: Mon, 3 Aug 2026 21:41:03 -0700 Subject: [PATCH] feat!: remove the Jetify Nix cache The Jetify Nix cache required a Jetify Cloud account: cache URIs and the S3 credentials backing them were fetched from the Jetify API using the logged-in session. Remove it as the first step of making Devbox account-free. Removed: - the `devbox cache` command tree (upload/copy, configure, credentials, enable, info) - internal/devbox/providers/nixcache, which fetched AWS credentials and cache URIs from the Jetify API - the substituter path in installNixPackagesToStore, so nix build no longer receives --extra-substituters or AWS credentials, and the build-from-source retry that existed only to recover from a failed cache build - the S3 narinfo probe in internal/devpkg; cache lookups now only query the public https://cache.nixos.org - internal/setup, the sudo-task framework whose only task was the nix cache host setup, and nix.IncludeDevboxConfig/restartDaemon, which added the user to nix trusted-users and wrote ~root/.aws/config - the nightly cache-upload workflow nix.CurrentConfig/Config.IsUserTrusted are kept: they are generic nix.conf helpers with their own tests. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/cache-upload.yml | 64 --- internal/boxcli/cache.go | 221 ---------- internal/boxcli/root.go | 1 - internal/devbox/cache.go | 119 ------ internal/devbox/packages.go | 69 --- .../devbox/providers/nixcache/nixcache.go | 192 --------- internal/devbox/providers/nixcache/setup.go | 300 ------------- internal/devpkg/narinfo_cache.go | 120 +----- internal/goutil/sync.go | 41 -- internal/nix/build.go | 18 +- internal/nix/cache.go | 38 -- internal/nix/config.go | 69 --- internal/nix/nix.go | 34 -- internal/setup/setup.go | 396 ------------------ internal/setup/setup_test.go | 208 --------- internal/telemetry/telemetry.go | 1 - 16 files changed, 10 insertions(+), 1881 deletions(-) delete mode 100644 .github/workflows/cache-upload.yml delete mode 100644 internal/boxcli/cache.go delete mode 100644 internal/devbox/cache.go delete mode 100644 internal/devbox/providers/nixcache/nixcache.go delete mode 100644 internal/devbox/providers/nixcache/setup.go delete mode 100644 internal/goutil/sync.go delete mode 100644 internal/nix/cache.go delete mode 100644 internal/setup/setup.go delete mode 100644 internal/setup/setup_test.go diff --git a/.github/workflows/cache-upload.yml b/.github/workflows/cache-upload.yml deleted file mode 100644 index 98e6af29f4a..00000000000 --- a/.github/workflows/cache-upload.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: cache-upload -# Uploads devbox nix dependencies to cache - -on: - push: - branches: - - main - workflow_dispatch: - schedule: - - cron: '30 8 * * *' # Run nightly at 8:30 UTC - -permissions: - contents: read - pull-requests: read - -defaults: - run: - shell: bash - -env: - DEVBOX_API_TOKEN: ${{ secrets.DEVBOX_API_TOKEN }} - DEVBOX_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEVBOX_DEBUG: 1 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # I think this should be added to individual nix commands within devbox, but this is quick fix for now - NIX_CONFIG: | - access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} - -jobs: - upload-cache: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - # Build devbox from scratch because released devbox has a bug that prevents - # DEVBOX_API_TOKEN use - # we can remove this after 0.10.6 is out. - - uses: actions/setup-go@v6 - with: - go-version-file: ./go.mod - - name: Build devbox - run: | - go build -o dist/devbox ./cmd/devbox - sudo mv ./dist/devbox /usr/local/bin/ - - # - name: Install devbox - # uses: jetify-com/devbox-install-action@v0.14.0 - # with: - # enable-cache: true - - # We upload twice, once before updating and once after. This shows a simple - # method to cache the latest current and latest dependencies. - # If we want read access to cache on multi-user nix installs (e.g. macos), - # we need to call devbox cache configure. This is currently not working - # as expected on CICD. - - name: Upload cache - run: | - devbox cache upload - devbox update - devbox cache upload diff --git a/internal/boxcli/cache.go b/internal/boxcli/cache.go deleted file mode 100644 index 1de1d5ae648..00000000000 --- a/internal/boxcli/cache.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright 2024 Jetify Inc. and contributors. All rights reserved. -// Use of this source code is governed by the license in the LICENSE file. - -package boxcli - -import ( - "encoding/json" - "fmt" - "os/user" - "slices" - - "github.com/MakeNowJust/heredoc/v2" - "github.com/pkg/errors" - "github.com/samber/lo" - "github.com/spf13/cobra" - "go.jetify.com/devbox/internal/devbox" - "go.jetify.com/devbox/internal/devbox/devopt" - "go.jetify.com/devbox/internal/devbox/providers/identity" - "go.jetify.com/devbox/internal/devbox/providers/nixcache" - nixv1alpha1 "go.jetify.com/pkg/api/gen/priv/nix/v1alpha1" -) - -type cacheFlags struct { - pathFlag - to string -} - -type credentialsFlags struct { - format string -} - -func cacheCmd() *cobra.Command { - flags := cacheFlags{} - cacheCommand := &cobra.Command{ - Use: "cache", - Short: "Collection of commands to interact with nix cache", - PersistentPreRunE: ensureNixInstalled, - } - - uploadCommand := &cobra.Command{ - Use: "upload [installable]", - Aliases: []string{"copy"}, // This mimics the nix 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. - Otherwise, all packages in the project will be uploaded. - To upload to specific cache, use --to flag. Otherwise, a cache from - the cache provider will be used, if available. - `), - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - return devbox.UploadInstallableToCache( - cmd.Context(), cmd.ErrOrStderr(), flags.to, args[0], - ) - } - box, err := devbox.Open(&devopt.Opts{ - Dir: flags.path, - Stderr: cmd.ErrOrStderr(), - }) - if err != nil { - return errors.WithStack(err) - } - return box.UploadProjectToCache(cmd.Context(), flags.to) - }, - } - - flags.pathFlag.register(uploadCommand) - uploadCommand.Flags().StringVar( - &flags.to, "to", "", "URI of the cache to copy to") - - cacheCommand.AddCommand(uploadCommand) - cacheCommand.AddCommand(cacheConfigureCmd()) - cacheCommand.AddCommand(cacheCredentialsCmd()) - cacheCommand.AddCommand(cacheEnableCmd()) - cacheCommand.AddCommand(cacheInfoCmd()) - - return cacheCommand -} - -func cacheConfigureCmd() *cobra.Command { - username := "" - cmd := &cobra.Command{ - Use: "configure", - Short: "Configure Nix to use the Devbox cache as a substituter", - Long: heredoc.Doc(` - Configure Nix to use the Devbox cache as a substituter. - - If the current Nix installation is multi-user, this command grants the Nix - daemon access to Devbox caches by making the following changes: - - - Adds the current user to Nix's list of trusted users in the system nix.conf. - - Adds the cache credentials to ~root/.aws/config. - - Configuration requires sudo, but only needs to happen once. The changes persist - across Devbox accounts and organizations. - - This command is a no-op for single-user Nix installs that aren't running the - Nix daemon. - `), - Hidden: true, - Args: cobra.MaximumNArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - if username == "" { - u, _ := user.Current() - username = u.Username - } - return nixcache.ConfigureReprompt(cmd.Context(), username) - }, - } - cmd.Flags().StringVar(&username, "user", "", "") - return cmd -} - -func cacheCredentialsCmd() *cobra.Command { - flags := credentialsFlags{} - cmd := &cobra.Command{ - Use: "credentials", - Short: "Output S3 cache credentials", - Hidden: true, - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - creds, err := nixcache.CachedCredentials(cmd.Context()) - if err != nil { - return err - } - - if flags.format == "sh" { - fmt.Printf("export AWS_ACCESS_KEY_ID=%q\n", creds.AccessKeyID) - fmt.Printf("export AWS_SECRET_ACCESS_KEY=%q\n", creds.SecretAccessKey) - fmt.Printf("export AWS_SESSION_TOKEN=%q\n", creds.SessionToken) - return nil - } - - out, err := json.Marshal(creds) - if err != nil { - return err - } - _, err = cmd.OutOrStdout().Write(out) - return err - }, - } - cmd.Flags().StringVar(&flags.format, "format", "json", "Output format, either json or sh") - return cmd -} - -func cacheEnableCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "enable", - Short: "Enable the Devbox Nix cache for your account", - Long: heredoc.Doc(` - Sign up or log in to a Jetify Cloud account and configure Nix to use the - account's Nix cache. - - For more about how Devbox configures Nix, see "devbox cache configure -h". - `), - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - auth, err := identity.AuthClient(identity.AuthRedirectCache) - if err != nil { - return err - } - sess, _ := auth.GetSessions() - needLogin := len(sess) == 0 - if needLogin { - _, err = auth.LoginFlow() - if err != nil { - return err - } - } - - needConfigure := !nixcache.IsConfigured(cmd.Context()) - if needConfigure { - u, _ := user.Current() - err = nixcache.ConfigureReprompt(cmd.Context(), u.Username) - if err != nil { - return err - } - } - - if !needConfigure && !needLogin { - fmt.Fprintln(cmd.OutOrStdout(), "The Devbox cache is already enabled for your account.") - } - return nil - }, - } - return cmd -} - -func cacheInfoCmd() *cobra.Command { - return &cobra.Command{ - Use: "info", - Short: "Output information about the nix cache", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - // TODO(gcurtis): We can also output info about the daemon config status - // here - caches, err := nixcache.Caches(cmd.Context()) - if err != nil { - return err - } - if len(caches) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No cache configured") - } - for _, cache := range caches { - isReadOnly := !slices.Contains( - cache.GetPermissions(), - nixv1alpha1.Permission_PERMISSION_WRITE, - ) - fmt.Fprintf( - cmd.OutOrStdout(), - "* %s %s\n", - cache.GetUri(), - lo.Ternary(isReadOnly, "(read-only)", ""), - ) - } - return nil - }, - } -} diff --git a/internal/boxcli/root.go b/internal/boxcli/root.go index 86f2011f160..40571594ed0 100644 --- a/internal/boxcli/root.go +++ b/internal/boxcli/root.go @@ -58,7 +58,6 @@ func RootCmd() *cobra.Command { if featureflag.Auth.Enabled() { command.AddCommand(authCmd()) } - command.AddCommand(cacheCmd()) command.AddCommand(createCmd()) command.AddCommand(secretsCmd()) command.AddCommand(generateCmd()) diff --git a/internal/devbox/cache.go b/internal/devbox/cache.go deleted file mode 100644 index 59abc4b4d05..00000000000 --- a/internal/devbox/cache.go +++ /dev/null @@ -1,119 +0,0 @@ -package devbox - -import ( - "context" - "errors" - "io" - - "github.com/samber/lo" - "go.jetify.com/devbox/internal/boxcli/usererr" - "go.jetify.com/devbox/internal/build" - "go.jetify.com/devbox/internal/debug" - "go.jetify.com/devbox/internal/devbox/providers/identity" - "go.jetify.com/devbox/internal/devbox/providers/nixcache" - "go.jetify.com/devbox/internal/devpkg" - "go.jetify.com/devbox/internal/nix" - "go.jetify.com/devbox/internal/ux" - "go.jetify.com/pkg/auth" -) - -func (d *Devbox) UploadProjectToCache( - ctx context.Context, - cacheURI string, -) error { - defer debug.FunctionTimer().End() - if cacheURI == "" { - var err error - cacheURI, err = getWriteCacheURI(ctx, d.stderr) - if err != nil { - return err - } - } - - creds, err := nixcache.CachedCredentials(ctx) - if err != nil && !errors.Is(err, auth.ErrNotLoggedIn) { - return err - } - - packages := lo.Filter(d.InstallablePackages(), devpkg.IsNix) - if err != nil || len(packages) == 0 { - return err - } - - for _, pkg := range packages { - inCache, err := pkg.AreAllOutputsInCache(ctx, d.stderr, cacheURI) - if err != nil { - return err - } - if inCache { - ux.Finfof(d.stderr, "Package %s is already in cache, skipping\n", pkg.Raw) - continue - } - ux.Finfof(d.stderr, "Uploading package %s to cache\n", pkg.Raw) - installables, err := pkg.Installables() - if err != nil { - return err - } - for _, installable := range installables { - err := nix.CopyInstallableToCache(ctx, d.stderr, cacheURI, installable, creds.Env()) - if err != nil { - return err - } - } - } - - return nil -} - -func UploadInstallableToCache( - ctx context.Context, - stderr io.Writer, - cacheURI, installable string, -) error { - if cacheURI == "" { - var err error - cacheURI, err = getWriteCacheURI(ctx, stderr) - if err != nil { - return err - } - } - - creds, err := nixcache.CachedCredentials(ctx) - if err != nil && !errors.Is(err, auth.ErrNotLoggedIn) { - return err - } - return nix.CopyInstallableToCache(ctx, stderr, cacheURI, installable, creds.Env()) -} - -func getWriteCacheURI( - ctx context.Context, - w io.Writer, -) (string, error) { - _, err := identity.GenSession(ctx) - if errors.Is(err, auth.ErrNotLoggedIn) { - return "", - usererr.New("You must be logged in to upload to a Nix cache.") - } - caches, err := nixcache.WriteCaches(ctx) - if err != nil { - return "", err - } - - if len(caches) == 0 { - slug, err := identity.GetOrgSlug(ctx) - if err != nil { - return "", err - } - return "", - usererr.New( - "You don't have permission to write to any Nix caches. To configure cache, go to "+ - "%s/teams/%s/devbox", - build.DashboardHostname(), - slug, - ) - } - if len(caches) > 1 { - ux.Fwarningf(w, "Multiple caches available, using %s.\n", caches[0].GetUri()) - } - return caches[0].GetUri(), nil -} diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index 5184682cb1e..ea08534e06f 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -19,17 +19,14 @@ import ( "github.com/pkg/errors" "github.com/samber/lo" "go.jetify.com/devbox/internal/devbox/devopt" - "go.jetify.com/devbox/internal/devbox/providers/nixcache" "go.jetify.com/devbox/internal/devconfig" "go.jetify.com/devbox/internal/devconfig/configfile" "go.jetify.com/devbox/internal/devpkg" "go.jetify.com/devbox/internal/devpkg/pkgtype" "go.jetify.com/devbox/internal/lock" - "go.jetify.com/devbox/internal/setup" "go.jetify.com/devbox/internal/shellgen" "go.jetify.com/devbox/internal/telemetry" "go.jetify.com/devbox/nix/flake" - "go.jetify.com/pkg/auth" "go.jetify.com/devbox/internal/boxcli/usererr" "go.jetify.com/devbox/internal/debug" @@ -472,26 +469,12 @@ func (d *Devbox) installPackages(ctx context.Context, mode installMode) error { } if err := d.installNixPackagesToStore(ctx, mode); err != nil { - if caches, _ := nixcache.CachedReadCaches(ctx); len(caches) > 0 { - err = d.handleInstallFailure(ctx, mode) - } return err } return d.InstallRunXPackages(ctx) } -func (d *Devbox) handleInstallFailure(ctx context.Context, mode installMode) error { - ux.Fwarningf(d.stderr, "Failed to build from cache, building from source.\n") - telemetry.Event(telemetry.EventNixBuildWithSubstitutersFailed, telemetry.Metadata{ - Packages: lo.Map( - d.InstallablePackages(), func(p *devpkg.Package, _ int) string { return p.Raw }), - }) - nixcache.DisableReadCaches() - devpkg.ClearNarInfoCache() - return d.installNixPackagesToStore(ctx, mode) -} - func (d *Devbox) InstallRunXPackages(ctx context.Context) error { for _, pkg := range lo.Filter(d.InstallablePackages(), devpkg.IsRunX) { lockedPkg, err := d.lockfile.Resolve(pkg.Raw) @@ -530,10 +513,6 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context, mode installMode Flags: flags, Writer: d.stderr, } - err = d.appendExtraSubstituters(ctx, args) - if err != nil { - return err - } packageNames := lo.Map( packages, @@ -576,54 +555,6 @@ func (d *Devbox) installNixPackagesToStore(ctx context.Context, mode installMode return nil } -func (d *Devbox) appendExtraSubstituters(ctx context.Context, args *nix.BuildArgs) error { - creds, err := nixcache.CachedCredentials(ctx) - if errors.Is(err, auth.ErrNotLoggedIn) { - return nil - } - if err != nil { - ux.Fwarningf(d.stderr, "Devbox was unable to authenticate with the Jetify Nix cache. Some packages might be built from source.\n") - return nil //nolint:nilerr - } - - caches, err := nixcache.CachedReadCaches(ctx) - if err != nil { - slog.Error("error getting list of caches from the Jetify API, assuming the user doesn't have access to any", "err", err) - return nil - } - if len(caches) == 0 { - return nil - } - - err = nixcache.Configure(ctx) - if errors.Is(err, setup.ErrAlreadyRefused) { - slog.Debug("user previously refused to configure nix cache, not re-prompting") - return nil - } - if errors.Is(err, setup.ErrUserRefused) { - ux.Finfof(d.stderr, "Skipping cache setup. Run `devbox cache configure` to enable the cache at a later time.\n") - return nil - } - var daemonErr *nix.DaemonError - if errors.As(err, &daemonErr) { - // Error here to give the user a chance to restart the daemon. - return usererr.New("Devbox configured Nix to use a new cache. Please restart the Nix daemon and re-run Devbox.") - } - // Other errors indicate we couldn't update nix.conf, so just warn and - // continue by building from source if necessary. - if err != nil { - slog.Error("error configuring nix cache", "err", err) - ux.Fwarningf(d.stderr, "Devbox was unable to configure Nix to use the Jetify Nix cache. Some packages might be built from source.\n") - return nil - } - - for _, cache := range caches { - args.ExtraSubstituters = append(args.ExtraSubstituters, cache.GetUri()) - } - args.Env = append(args.Env, creds.Env()...) - return nil -} - func (d *Devbox) packagesToInstallInStore(ctx context.Context, mode installMode) ([]*devpkg.Package, error) { defer debug.FunctionTimer().End() // First, get and prepare all the packages that must be installed in this project diff --git a/internal/devbox/providers/nixcache/nixcache.go b/internal/devbox/providers/nixcache/nixcache.go deleted file mode 100644 index 34bb575ae29..00000000000 --- a/internal/devbox/providers/nixcache/nixcache.go +++ /dev/null @@ -1,192 +0,0 @@ -package nixcache - -import ( - "context" - "fmt" - "slices" - "time" - - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/pkg/errors" - "github.com/samber/lo" - "go.jetify.com/devbox/internal/build" - "go.jetify.com/devbox/internal/cachehash" - "go.jetify.com/devbox/internal/devbox/providers/identity" - "go.jetify.com/devbox/internal/goutil" - "go.jetify.com/devbox/internal/redact" - "go.jetify.com/pkg/api" - nixv1alpha1 "go.jetify.com/pkg/api/gen/priv/nix/v1alpha1" - "go.jetify.com/pkg/auth" - "go.jetify.com/pkg/auth/session" - "go.jetify.com/pkg/filecache" -) - -var cachedCredentials = goutil.OnceValuesWithContext( - func(ctx context.Context) (AWSCredentials, error) { - // Adding version to caches to avoid conflicts if we want to update the schema - // or while working on dev. - cache := filecache.New[AWSCredentials](fmt.Sprintf( - "devbox/%s/providers/nixcache", - build.Version, - )) - token, err := identity.GenSession(ctx) - if err != nil { - return AWSCredentials{}, err - } - creds, err := cache.GetOrSetWithTime( - "credentials-"+getSubOrAccessTokenHash(token), - func() (AWSCredentials, time.Time, error) { - token, err := identity.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 - }) - -// CachedCredentials fetches short-lived credentials that grant access to the user's -// private cache. -func CachedCredentials(ctx context.Context) (AWSCredentials, error) { - return cachedCredentials.Do(ctx) -} - -// Caches return the list of caches the user has access to. If user is not -// logged in, it returns nil, nil. (no error). -func Caches( - ctx context.Context, -) ([]*nixv1alpha1.NixBinCache, error) { - token, err := identity.GenSession(ctx) - if errors.Is(err, auth.ErrNotLoggedIn) { - return nil, nil - } else if err != nil { - return nil, err - } - client := api.NewClient(ctx, build.JetpackAPIHost(), token) - resp, err := client.GetBinCache(ctx) - if err != nil { - return nil, redact.Errorf("nixcache: get caches: %w", redact.Safe(err)) - } - return resp.GetCaches(), nil -} - -var cachedReadCaches = goutil.OnceValuesWithContext( - func(ctx context.Context) ([]*nixv1alpha1.NixBinCache, error) { - caches, err := Caches(ctx) - if err != nil { - return nil, err - } - return slices.DeleteFunc(caches, func(c *nixv1alpha1.NixBinCache) bool { - return !slices.Contains(c.GetPermissions(), nixv1alpha1.Permission_PERMISSION_READ) - }), nil - }, -) - -func CachedReadCaches(ctx context.Context) ([]*nixv1alpha1.NixBinCache, error) { - return cachedReadCaches.Do(ctx) -} - -func DisableReadCaches() { - cachedReadCaches = goutil.OnceValuesWithContext( - func(ctx context.Context) ([]*nixv1alpha1.NixBinCache, error) { - return nil, nil - }, - ) -} - -func WriteCaches( - ctx context.Context, -) ([]*nixv1alpha1.NixBinCache, error) { - caches, err := Caches(ctx) - if err != nil { - return nil, err - } - return lo.Filter(caches, func(c *nixv1alpha1.NixBinCache, _ int) bool { - return slices.Contains( - c.GetPermissions(), - nixv1alpha1.Permission_PERMISSION_WRITE, - ) - }), nil -} - -func S3Client( - ctx context.Context, -) (*s3.Client, error) { - creds, err := CachedCredentials(ctx) - if err != nil { - return nil, err - } - config, err := config.LoadDefaultConfig( - ctx, - config.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider( - creds.AccessKeyID, - creds.SecretAccessKey, - creds.SessionToken, - ), - ), - ) - if err != nil { - return nil, errors.WithStack(err) - } - - return s3.NewFromConfig(config), nil -} - -// 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, - } -} - -func getSubOrAccessTokenHash(token *session.Token) string { - // We need this because the token is missing IDToken when used in CICD. - // TODO: Implement AccessToken Parsing so we can extract sub form that. - if token.IDClaims() != nil && token.IDClaims().Subject != "" { - return token.IDClaims().Subject - } - return cachehash.Bytes([]byte(token.AccessToken)) -} diff --git a/internal/devbox/providers/nixcache/setup.go b/internal/devbox/providers/nixcache/setup.go deleted file mode 100644 index 91c6c59ded0..00000000000 --- a/internal/devbox/providers/nixcache/setup.go +++ /dev/null @@ -1,300 +0,0 @@ -package nixcache - -import ( - "context" - "errors" - "fmt" - "io/fs" - "log/slog" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" - "time" - "unicode" - - "go.jetify.com/devbox/internal/envir" - "go.jetify.com/devbox/internal/nix" - "go.jetify.com/devbox/internal/redact" - "go.jetify.com/devbox/internal/setup" - "go.jetify.com/devbox/internal/ux" -) - -const setupKey = "nixcache-setup" - -func IsConfigured(ctx context.Context) bool { - u, err := user.Current() - if err != nil { - return false - } - task := &setupTask{u.Username} - status := setup.Status(ctx, setupKey, task) - return status == setup.TaskDone -} - -func Configure(ctx context.Context) error { - u, err := user.Current() - if err != nil { - return redact.Errorf("nixcache: lookup current user: %v", err) - } - - task := &setupTask{u.Username} - - // This function might be called from other Devbox commands - // (such as devbox add), so we need to provide some context in the sudo - // prompt. - const sudoPrompt = "You're logged into a Devbox account, but Nix isn't setup to use your account's caches. " + - "Allow sudo to configure Nix?" - err = setup.ConfirmRun(ctx, setupKey, task, sudoPrompt) - if err != nil { - return redact.Errorf("nixcache: run setup: %w", err) - } - return nil -} - -func ConfigureReprompt(ctx context.Context, username string) error { - setup.Reset(setupKey) - task := &setupTask{username} - - // We're reprompting, so the user explicitly asked to configure the - // cache. We can keep the sudo prompt short. - err := setup.ConfirmRun(ctx, setupKey, task, "Allow sudo to configure Nix?") - if err != nil { - return redact.Errorf("nixcache: run setup: %w", err) - } - return nil -} - -// setupTask adds the user to Nix's trusted-users list and updates -// ~root/.aws/config so that they can use their Devbox cache with the -// Nix daemon. -type setupTask struct { - // username is the OS username to trust. - username string -} - -func (s *setupTask) NeedsRun(ctx context.Context, lastRun setup.RunInfo) bool { - if _, err := nix.DaemonVersion(ctx); err != nil { - // This looks like a single-user install, so no need to - // configure the daemon or root's AWS credentials. - slog.Error("nixcache: skipping setup: error connecting to nix daemon, assuming single-user install", "err", err) - return false - } - - if lastRun.Time.IsZero() { - slog.Debug("nixcache: running setup: first time setup") - return true - } - cfg, err := nix.CurrentConfig(ctx) - if err != nil { - slog.Error("nixcache: running setup: error getting current nix config, assuming user isn't trusted", "user", s.username) - return true - } - trusted, err := cfg.IsUserTrusted(ctx, s.username) - if err != nil { - slog.Error("nixcache: running setup: error checking if user is trusted, assuming they aren't", "user", s.username) - return true - } - if !trusted { - slog.Debug("nixcache: running setup: user isn't trusted", "user", s.username) - return true - } - return false -} - -func (s *setupTask) Run(ctx context.Context) error { - ran, err := setup.SudoDevbox(ctx, "cache", "configure", "--user", s.username) - if ran || err != nil { - return err - } - - // Update the AWS config before configuring and restarting the Nix - // daemon. - err = s.updateAWSConfig() - if err != nil { - return redact.Errorf("update root aws config: %v", err) - } - - trusted := false - cfg, err := nix.CurrentConfig(ctx) - if err == nil { - trusted, _ = cfg.IsUserTrusted(ctx, s.username) - } - if !trusted { - err = nix.IncludeDevboxConfig(ctx, s.username) - if errors.Is(err, nix.ErrUnknownServiceManager) { - ux.Fwarningf(os.Stderr, "Devbox configured Nix to use a new cache. Please restart the Nix daemon and re-run Devbox.\n") - } else if err != nil { - return redact.Errorf("update nix config: %v", err) - } - } - return nil -} - -func (s *setupTask) updateAWSConfig() error { - exe, err := devboxExecutable() - if err != nil { - return err - } - sudo, err := sudoExecutable() - if err != nil { - return err - } - configPath, err := rootAWSConfigPath() - if err != nil { - return err - } - - // Clear out and backup any existing .aws directory. We need to - // do this with the entire directory and not just .aws/config - // because there are other files that can affect credentials. - backup, err := backupDirectory(filepath.Dir(configPath)) - if err != nil { - return err - } - - flag := os.O_WRONLY | os.O_CREATE | os.O_EXCL - perm := fs.FileMode(0o644) - config, err := os.OpenFile(configPath, flag, perm) - if errors.Is(err, os.ErrNotExist) { - // Avoid os.MkdirAll because we shouldn't be creating anything - // above the user's home directory. - if err = os.Mkdir(filepath.Dir(configPath), 0o755); err != nil { - return redact.Errorf("create ~root/.aws directory: %v", err) - } - config, err = os.OpenFile(configPath, flag, perm) - } - if err != nil { - return redact.Errorf("open ~root/.aws/config: %v", err) - } - defer config.Close() - - // TODO(gcurtis): it would be nice to use a non-default profile - // if https://github.com/NixOS/nix/issues/5525 gets fixed. - header := "# This file was generated by Devbox.\n" - if backup != "" { - header += "# The old .aws directory was moved to " + backup + ".\n" - } - _, err = fmt.Fprintf(config, `%s -[default] -# sudo as the configured user so that their cached credential files have the -# correct ownership. -credential_process = %s -u %s -i %s-- %s cache credentials -`, header, sudo, s.username, propagatedEnv(), exe) - if err != nil { - return redact.Errorf("write to ~root/.aws/config: %v", err) - } - if err := config.Close(); err != nil { - return redact.Errorf("close ~root/.aws/config: %v", err) - } - return nil -} - -// propagatedEnv returns a string of space-separated VAR=value pairs of -// environment variables that should be propagated to the credential_process -// command in ~root/.aws/config. This is especially important for CI because the -// Nix daemon won't otherwise see any environment variables set by the job. -func propagatedEnv() string { - envs := []string{ - "DEVBOX_API_TOKEN", - "DEVBOX_PROD", - "DEVBOX_USE_VERSION", - "XDG_CACHE_HOME", - "XDG_CONFIG_DIRS", - "XDG_CONFIG_HOME", - "XDG_DATA_DIRS", - "XDG_DATA_HOME", - "XDG_RUNTIME_DIR", - "XDG_STATE_HOME", - } - strb := strings.Builder{} - for _, name := range envs { - val := os.Getenv(name) - if val == "" { - continue - } - notPrintable := strings.ContainsFunc(val, func(r rune) bool { - return !unicode.IsPrint(r) - }) - if notPrintable { - slog.Debug("nixcache: not including environment variable in ~root/.aws/config because it contains nonprintable runes: %q=%q", name, val) - continue - } - - strb.WriteString(name) - strb.WriteString(`="`) - for _, r := range val { - switch r { - // Special characters inside double quotes: - // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_02_03 - case '$', '`', '"', '\\': - strb.WriteByte('\\') - } - strb.WriteRune(r) - } - strb.WriteString(`" `) - } - return strb.String() -} - -// rootAWSConfigPath returns the default AWS config path for the root user. In a -// shell this is ~root/.aws/config. -func 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 -} - -// backupDirectory creates a backup of a directory and then deletes it. Upon -// success, it returns the path to the backup copy. -func backupDirectory(path string) (string, error) { - // Remember this function is running as root, so be careful when - // moving/creating/deleting things. - - path = filepath.Clean(path) - if path == "/" { - return "", redact.Errorf("refusing to backup root directory") - } - - backup := fmt.Sprintf("%s-%d.bak", path, time.Now().Unix()) - err := os.Rename(path, backup) - if errors.Is(err, os.ErrNotExist) { - // No pre-existing .aws directory. - return "", nil - } - if err != nil { - return "", redact.Errorf("backup existing directory %s: %v", path, err) - } - return backup, nil -} - -// devboxExecutable returns the path to the Devbox launcher script or the -// current binary if the launcher is unavailable. -func devboxExecutable() (string, error) { - if exe := os.Getenv(envir.LauncherPath); exe != "" { - if abs, err := filepath.Abs(exe); err == nil { - return abs, nil - } - } - - exe, err := os.Executable() - if err != nil { - return "", redact.Errorf("get path to devbox executable: %v", err) - } - return exe, nil -} - -// sudoExecutable searches the PATH for sudo. -func sudoExecutable() (string, error) { - sudo, err := exec.LookPath("sudo") - if err != nil { - return "", redact.Errorf("get path to sudo executable: %v", err) - } - return sudo, nil -} diff --git a/internal/devpkg/narinfo_cache.go b/internal/devpkg/narinfo_cache.go index e39fd2a6cfc..9761432e3e1 100644 --- a/internal/devpkg/narinfo_cache.go +++ b/internal/devpkg/narinfo_cache.go @@ -3,19 +3,11 @@ package devpkg import ( "context" "fmt" - "io" "net/http" - "net/url" - "strings" "sync" "time" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/pkg/errors" "go.jetify.com/devbox/internal/debug" - "go.jetify.com/devbox/internal/devbox/providers/nixcache" - "go.jetify.com/devbox/internal/goutil" "go.jetify.com/devbox/internal/lock" "go.jetify.com/devbox/internal/nix" "golang.org/x/sync/errgroup" @@ -125,11 +117,6 @@ func (p *Package) fetchNarInfoStatusOnce( ctx := context.TODO() outputToCache := map[string]string{} - caches, err := readCaches(ctx) - if err != nil { - return nil, err - } - outputs, err := p.outputsForOutputName(outputName) if err != nil { return nil, err @@ -138,56 +125,18 @@ func (p *Package) fetchNarInfoStatusOnce( for _, output := range outputs { pathParts := nix.NewStorePathParts(output.Path) hash := pathParts.Hash - for _, cache := range caches { - inCache := false - if strings.HasPrefix(cache, "s3") { - inCache, err = fetchNarInfoStatusFromS3(ctx, cache, hash) - if err != nil { - return nil, err - } - } else { - inCache, err = fetchNarInfoStatusFromHTTP(ctx, cache, hash) - if err != nil { - return nil, err - } - } - if inCache { - // Found it, no need to check more caches. - outputToCache[output.Name] = cache - break - } + inCache, err := fetchNarInfoStatusFromHTTP(ctx, binaryCache, hash) + if err != nil { + return nil, err + } + if inCache { + outputToCache[output.Name] = binaryCache } } return outputToCache, nil } -func (p *Package) AreAllOutputsInCache( - ctx context.Context, w io.Writer, cacheURI string, -) (bool, error) { - storePaths, err := p.GetStorePaths(ctx, w) - if err != nil { - return false, err - } - - for _, storePath := range storePaths { - pathParts := nix.NewStorePathParts(storePath) - hash := pathParts.Hash - if strings.HasPrefix(cacheURI, "s3") { - inCache, err := fetchNarInfoStatusFromS3(ctx, cacheURI, hash) - if err != nil || !inCache { - return false, err - } - } else { - inCache, err := fetchNarInfoStatusFromHTTP(ctx, cacheURI, hash) - if err != nil || !inCache { - return false, err - } - } - } - return true, nil -} - func (p *Package) outputsForOutputName(output string) ([]lock.Output, error) { sysInfo, err := p.sysInfoIfExists() if err != nil || sysInfo == nil { @@ -281,60 +230,3 @@ func fetchNarInfoStatusFromHTTP( )) return fetch.(func() (bool, error))() } - -func fetchNarInfoStatusFromS3( - ctx context.Context, - uri string, - hash string, -) (bool, error) { - key := fmt.Sprintf("%s/%s", uri, hash) - fetch, _ := narInfoStatusFnCache.LoadOrStore(key, sync.OnceValues( - func() (bool, error) { - s3Client, err := nixcache.S3Client(ctx) - if err != nil { - return false, err - } - - bucketURI, err := url.Parse(uri) - if err != nil { - return false, errors.WithStack(err) - } - - _, err = s3Client.GetObject(ctx, - &s3.GetObjectInput{ - Bucket: aws.String(bucketURI.Hostname()), - Key: aws.String(hash + ".narinfo"), - }, - func(o *s3.Options) { - if bucketURI.Query().Get("region") != "" { - o.Region = bucketURI.Query().Get("region") - } - }, - ) - return err == nil, nil - }, - )) - return fetch.(func() (bool, error))() -} - -var nixCacheIsConfigured = goutil.OnceValueWithContext(nixcache.IsConfigured) - -func readCaches(ctx context.Context) ([]string, error) { - cacheURIs := []string{binaryCache} - if !nixCacheIsConfigured.Do(ctx) { - return cacheURIs, nil - } - - otherCaches, err := nixcache.CachedReadCaches(ctx) - if err != nil { - return nil, err - } - for _, c := range otherCaches { - cacheURIs = append(cacheURIs, c.GetUri()) - } - return cacheURIs, nil -} - -func ClearNarInfoCache() { - narInfoStatusFnCache = sync.Map{} -} diff --git a/internal/goutil/sync.go b/internal/goutil/sync.go deleted file mode 100644 index f75aa388a4d..00000000000 --- a/internal/goutil/sync.go +++ /dev/null @@ -1,41 +0,0 @@ -package goutil - -import ( - "context" - "sync" -) - -type onceValue[T any] struct { - once sync.Once - fn func(context.Context) T - result T -} - -func OnceValueWithContext[T any](fn func(context.Context) T) *onceValue[T] { - return &onceValue[T]{fn: fn} -} - -func (o *onceValue[T]) Do(ctx context.Context) T { - o.once.Do(func() { - o.result = o.fn(ctx) - }) - return o.result -} - -type onceValues[T any] struct { - once sync.Once - fn func(context.Context) (T, error) - result T - err error -} - -func OnceValuesWithContext[T any](fn func(context.Context) (T, error)) *onceValues[T] { - return &onceValues[T]{fn: fn} -} - -func (o *onceValues[T]) Do(ctx context.Context) (T, error) { - o.once.Do(func() { - o.result, o.err = o.fn(ctx) - }) - return o.result, o.err -} diff --git a/internal/nix/build.go b/internal/nix/build.go index 5e33d667955..0974ea31f78 100644 --- a/internal/nix/build.go +++ b/internal/nix/build.go @@ -5,17 +5,15 @@ import ( "io" "log/slog" "os" - "strings" "go.jetify.com/devbox/internal/debug" ) type BuildArgs struct { - AllowInsecure bool - Env []string - ExtraSubstituters []string - Flags []string - Writer io.Writer + AllowInsecure bool + Env []string + Flags []string + Writer io.Writer } func Build(ctx context.Context, args *BuildArgs, installables ...string) error { @@ -27,14 +25,6 @@ func Build(ctx context.Context, args *BuildArgs, installables ...string) error { cmd := Command("build", "--impure") cmd.Args = appendArgs(cmd.Args, args.Flags) cmd.Args = appendArgs(cmd.Args, installables) - // Adding extra substituters only here to be conservative, but this could also - // be added to ExperimentalFlags() in the future. - if len(args.ExtraSubstituters) > 0 { - cmd.Args = append(cmd.Args, - "--extra-substituters", - strings.Join(args.ExtraSubstituters, " "), - ) - } cmd.Env = append(allowUnfreeEnv(os.Environ()), args.Env...) if args.AllowInsecure { slog.Debug("Setting Allow-insecure env-var\n") diff --git a/internal/nix/cache.go b/internal/nix/cache.go deleted file mode 100644 index 6047ccc09e1..00000000000 --- a/internal/nix/cache.go +++ /dev/null @@ -1,38 +0,0 @@ -package nix - -import ( - "context" - "fmt" - "io" - "os" -) - -func CopyInstallableToCache( - ctx context.Context, - out io.Writer, - // Note: installable is a string instead of a flake.Installable - // because flake.Installable does not support store paths yet. It converts - // paths into "path" flakes which is not what we want for /nix/store paths. - // TODO: Add support for store paths in flake.Installable - to, installable string, - env []string, -) error { - fmt.Fprintf(out, "Copying %s to %s\n", installable, to) - cmd := Command( - "copy", "--to", to, - // --impure makes NIXPKGS_ALLOW_* environment variables work. - "--impure", - // --refresh checks the cache to ensure it is up to date. Otherwise if - // anything has was copied previously from this machine and then purged - // it may not be copied again. It's fairly fast, but not instant. - "--refresh", - installable, - ) - - cmd.Stdin = os.Stdin - cmd.Stdout = out - cmd.Stderr = out - cmd.Env = append(allowUnfreeEnv(allowInsecureEnv(os.Environ())), env...) - - return cmd.Run(ctx) -} diff --git a/internal/nix/config.go b/internal/nix/config.go index 4d102406882..99ea19eff10 100644 --- a/internal/nix/config.go +++ b/internal/nix/config.go @@ -1,22 +1,16 @@ package nix import ( - "cmp" "context" "encoding/json" "errors" - "fmt" - "io" "log/slog" - "os" "os/exec" "os/user" - "path/filepath" "slices" "strings" "go.jetify.com/devbox/internal/redact" - "go.jetify.com/devbox/nix" ) // Config is a parsed Nix configuration. @@ -105,66 +99,3 @@ func (c Config) IsUserTrusted(ctx context.Context, username string) (bool, error } return false, nil } - -func IncludeDevboxConfig(ctx context.Context, username string) error { - info, _ := nix.Default.Info() - path := cmp.Or(info.SystemConfig, "/etc/nix/nix.conf") - includePath := filepath.Join(filepath.Dir(path), "devbox-nix.conf") - b := fmt.Appendf(nil, "# This config was auto-generated by Devbox.\n\nextra-trusted-users = %s\n", username) - if err := os.WriteFile(includePath, b, 0o664); err != nil { - return redact.Errorf("write devbox nix.conf: %v", err) - } - - appended, err := appendConfigInclude(path, includePath) - if err != nil { - return err - } - if appended { - return restartDaemon(ctx) - } - return nil -} - -func appendConfigInclude(srcPath, includePath string) (appended bool, err error) { - nixConf, err := os.OpenFile(srcPath, os.O_RDWR, 0) - if err != nil { - return false, err - } - defer nixConf.Close() - - confb, err := io.ReadAll(nixConf) - if err != nil { - return false, err - } - for _, line := range strings.Split(string(confb), "\n") { - line = strings.TrimSpace(line) - if line == "" { - // - continue - } - if strings.HasPrefix(line, "#") { - // # comment - continue - } - - path := strings.TrimSpace(strings.TrimPrefix(line, "include")) - if path == includePath { - // include devbox-nix.conf - return false, nil - } - path = strings.TrimSpace(strings.TrimPrefix(line, "!include")) - if path == includePath { - // !include devbox-nix.conf - return false, nil - } - } - - include := "\ninclude " + includePath + "\n" - if _, err := nixConf.WriteString(include); err != nil { - return false, redact.Errorf("append %q to %s: %v", redact.Safe(include), srcPath, err) - } - if err := nixConf.Close(); err != nil { - return false, redact.Errorf("append %q to %s: %v", redact.Safe(include), srcPath, err) - } - return true, nil -} diff --git a/internal/nix/nix.go b/internal/nix/nix.go index c25444adb4e..3b99d2d4760 100644 --- a/internal/nix/nix.go +++ b/internal/nix/nix.go @@ -13,10 +13,8 @@ import ( "os/exec" "path/filepath" "regexp" - "runtime" "runtime/trace" "strings" - "time" "github.com/pkg/errors" "go.jetify.com/devbox/internal/boxcli/featureflag" @@ -228,38 +226,6 @@ func parseInsecurePackagesFromExitError(errorMsg string) []string { return insecurePackages } -var ErrUnknownServiceManager = errors.New("unknown service manager") - -func restartDaemon(ctx context.Context) error { - if runtime.GOOS != "darwin" { - err := fmt.Errorf("don't know how to restart nix daemon: %w", ErrUnknownServiceManager) - return &DaemonError{err: err} - } - - cmd := exec.CommandContext(ctx, "launchctl", "bootout", "system", "/Library/LaunchDaemons/org.nixos.nix-daemon.plist") - out, err := cmd.CombinedOutput() - if err != nil { - return &DaemonError{ - cmd: cmd.String(), - stderr: out, - err: fmt.Errorf("stop nix daemon: %w", err), - } - } - cmd = exec.CommandContext(ctx, "launchctl", "bootstrap", "system", "/Library/LaunchDaemons/org.nixos.nix-daemon.plist") - out, err = cmd.CombinedOutput() - if err != nil { - return &DaemonError{ - cmd: cmd.String(), - stderr: out, - err: fmt.Errorf("start nix daemon: %w", err), - } - } - - // TODO(gcurtis): poll for daemon to come back instead. - time.Sleep(2 * time.Second) - return nil -} - // FixInstallableArgs removes the narHash and lastModifiedDate query parameters // from any args that are valid installables and the Nix version is <2.25. // Otherwise it returns them unchanged. diff --git a/internal/setup/setup.go b/internal/setup/setup.go deleted file mode 100644 index fc40de88875..00000000000 --- a/internal/setup/setup.go +++ /dev/null @@ -1,396 +0,0 @@ -// Package setup performs setup tasks and records metadata about when they're -// run. -package setup - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/mattn/go-isatty" - "go.jetify.com/devbox/internal/build" - "go.jetify.com/devbox/internal/debug" - "go.jetify.com/devbox/internal/envir" - "go.jetify.com/devbox/internal/redact" - "go.jetify.com/devbox/internal/xdg" -) - -// ErrUserRefused indicates that the user responded no to an interactive -// confirmation prompt. -var ErrUserRefused = errors.New("user refused run") - -// ErrAlreadyRefused indicates that no confirmation prompt was shown because the -// user previously refused to run the task. Call [Reset] to re-prompt the user -// for confirmation. -var ErrAlreadyRefused = errors.New("already refused by user") - -type ctxKey string - -// ctxKeyTask tracks the current task key across processes when relaunching -// with sudo. -var ctxKeyTask ctxKey = "task" - -// Task is a setup action that can conditionally run based on the state of a -// previous run. -type Task interface { - Run(ctx context.Context) error - - // NeedsRun returns true if the task needs to be run. It should assume - // that lastRun persists across executions of the program and is unique - // for each user. - // - // A task that should only run once can check if lastRun.Time is the zero value. - // A task that only runs after an update can check if lastRun.Version < build.Version. - // A retryable task can check lastRun.Error to see if the previous run failed. - NeedsRun(ctx context.Context, lastRun RunInfo) bool -} - -// RunInfo contains metadata that describes the most recent run of a task. -type RunInfo struct { - // Time is the last time the task ran. - Time time.Time `json:"time"` - - // Version is the version of Devbox that last ran the task. - Version string `json:"version"` - - // Error is the error message returned by the last run. It's empty if - // the last run succeeded. - Error string `json:"error,omitempty"` -} - -// TaskStatus describes the status of a task. -type TaskStatus int - -const ( - // TaskDone indicates that a task doesn't need to run and that its most - // recent run (if any) didn't report an error. Note that a task can be - // done without ever running if its NeedsRun method returns false before - // the first run. - TaskDone TaskStatus = iota - - // TaskNeedsRun is the status of a task that needs to be run. - TaskNeedsRun - - // TaskUserRefused indicates that the user answered no to a confirmation - // prompt to run the task. - TaskUserRefused - - // TaskError indicates that a task's most recent run failed and it - // cannot be re-run without a call to [Reset]. - TaskError - - // TaskSudoing occurs when the caller of [Status] is running in a sudoed - // process due to the task calling [SudoDevbox] from the parent process. - TaskSudoing -) - -// Status returns the status of a setup task. -func Status(ctx context.Context, key string, task Task) TaskStatus { - defer debug.FunctionTimer().End() - state := loadState(key) - switch { - case isSudo(key): - return TaskSudoing - case state.ConfirmPrompt.Asked && !state.ConfirmPrompt.Allowed: - return TaskUserRefused - case task.NeedsRun(ctx, state.LastRun): - return TaskNeedsRun - case state.LastRun.Error == "": - return TaskDone - case state.LastRun.Error != "": - return TaskError - } - panic("setup.Status switch isn't exhaustive") -} - -// Run runs a setup task and stores its state under a given key. Keys are -// namespaced by user. It only calls the task's Run method when NeedsRun returns -// true. -func Run(ctx context.Context, key string, task Task) error { - return run(ctx, key, task, "") -} - -// SudoDevbox relaunches Devbox as root using sudo, taking care to preserve -// Devbox environment variables that can affect the new process. If the current -// user is already root, then it returns (false, nil) to indicate that no sudo -// process ran. The caller can use this as a hint to know if it's running as the -// sudoed process. Typical usage is: -// -// func (*ConfigTask) Run(context.Context) error { -// ran, err := SudoDevbox(ctx, "cache", "configure") -// if ran || err != nil { -// // return early if we kicked off a sudo process or there -// // was an error -// return err -// } -// // do things as root -// } -// -// ConfirmRun(ctx, key, &ConfigTask{}, "Allow sudo to run Devbox as root?") -// -// A task that calls SudoDevbox should pass command arguments that cause the new -// Devbox process to rerun the task. The task executes unconditionally within -// the sudo process without re-prompting the user or a second call to its -// NeedsRun method. -func SudoDevbox(ctx context.Context, arg ...string) (ran bool, err error) { - if os.Getuid() == 0 { - return false, nil - } - - taskKey := "" - if v := ctx.Value(ctxKeyTask); v != nil { - taskKey = v.(string) - } - - // Ensure the state file and its directory exist before sudoing, - // otherwise they will be owned by root. This is easier than recursively - // chowning new directories/files after root creates them. - if taskKey != "" { - saveState(taskKey, state{}) - } - - // Use the absolute path to Devbox instead of relying on PATH for two - // reasons: - // - // 1. sudo isn't guaranteed to preserve the current PATH and the root - // user might not have devbox in its PATH. - // 2. If we're running an alternative version of Devbox - // (such as a dev build) we want to use the same binary. - exe, err := devboxExecutable() - if err != nil { - return false, err - } - - sudoArgs := make([]string, 0, len(arg)+4) - sudoArgs = append(sudoArgs, "--preserve-env="+strings.Join([]string{ - // Keep writing debug logs from the sudo process. - "DEVBOX_DEBUG", - - // Use the same Devbox API and auth token. - "DEVBOX_API_TOKEN", - "DEVBOX_PROD", - - // In case the Devbox version is overridden. - "DEVBOX_USE_VERSION", - - // Use the same XDG directories for state, caching, etc. - "XDG_CACHE_HOME", - "XDG_CONFIG_DIRS", - "XDG_CONFIG_HOME", - "XDG_DATA_DIRS", - "XDG_DATA_HOME", - "XDG_RUNTIME_DIR", - "XDG_STATE_HOME", - }, ",")) - if taskKey != "" { - sudoArgs = append(sudoArgs, "DEVBOX_SUDO_TASK="+taskKey) - } - sudoArgs = append(sudoArgs, "--", exe) - sudoArgs = append(sudoArgs, arg...) - - cmd := exec.CommandContext(ctx, "sudo", sudoArgs...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - if taskKey == "" { - return false, redact.Errorf("setup: relaunch with sudo: %w", err) - } - return false, taskError(taskKey, redact.Errorf("relaunch with sudo: %w", err)) - } - return true, nil -} - -// Run interactively prompts the user to confirm that it's ok to run a setup -// task. It only prompts the user if the task's NeedsRun method returns true. If -// the user refuses to run the task, then ConfirmPrompt will not ask them again. -// Call [Reset] to reset the task's state and re-prompt the user. -func ConfirmRun(ctx context.Context, key string, task Task, prompt string) error { - if prompt == "" { - return taskError(key, redact.Errorf("empty confirmation prompt")) - } - return run(ctx, key, task, prompt) -} - -var defaultPrompt = func(msg string) (response any, err error) { - if isatty.IsTerminal(os.Stdin.Fd()) { - err = survey.AskOne(&survey.Confirm{ - Message: msg, - Default: true, - }, &response) - return response, err - } - slog.Debug("setup: no tty detected, assuming yes to confirmation prompt", "prompt", msg) - return true, nil -} - -func run(ctx context.Context, key string, task Task, prompt string) error { - ctx = context.WithValue(ctx, ctxKeyTask, key) - - isSudo := isSudo(key) - state := loadState(key) - if !isSudo && !task.NeedsRun(ctx, state.LastRun) { - return nil - } - - oldState, newState := state, &state - defer func() { - if oldState != *newState { - saveState(key, *newState) - } - }() - - if !isSudo && prompt != "" { - state.ConfirmPrompt.Message = prompt - if state.ConfirmPrompt.Asked && !state.ConfirmPrompt.Allowed { - // We've asked before and the user said no. - return taskError(key, ErrAlreadyRefused) - } - - resp, err := defaultPrompt(prompt) - if err != nil { - return taskError(key, redact.Errorf("prompt for confirmation: %v", err)) - } - state.ConfirmPrompt.Asked = true - state.ConfirmPrompt.Allowed, _ = resp.(bool) - if !state.ConfirmPrompt.Allowed { - return taskError(key, ErrUserRefused) - } - } - - state.LastRun = RunInfo{ - Time: time.Now(), - Version: build.Version, - } - if err := task.Run(ctx); err != nil { - state.LastRun.Error = err.Error() - return taskError(key, err) - } - return nil -} - -// Reset removes a task's state so that it acts as if it has never run. -func Reset(key string) { - err := os.Remove(statePath(key)) - if errors.Is(err, os.ErrNotExist) { - return - } - if err != nil { - err = taskError(key, fmt.Errorf("remove state file: %v", err)) - slog.Error("ignoring setup task reset error", "err", err, "task", key) - } -} - -type state struct { - ConfirmPrompt confirmPrompt `json:"confirm_prompt,omitempty"` - LastRun RunInfo `json:"last_run,omitempty"` -} - -type confirmPrompt struct { - Message string `json:"message"` - Asked bool `json:"asked"` - Allowed bool `json:"allowed"` -} - -func loadState(key string) state { - path := statePath(key) - b, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - err = taskError(key, fmt.Errorf("load state file: %v", err)) - slog.Error("using empty setup task state due to an error", "err", err, "task", key) - } - return state{} - } - loaded := state{} - if err := json.Unmarshal(b, &loaded); err != nil { - err = taskError(key, fmt.Errorf("load state file %s: %v", path, err)) - slog.Error("using empty setup task state due to an error", "err", err, "task", key) - return state{} - } - return loaded -} - -func saveState(key string, s state) { - path := statePath(key) - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - err = taskError(key, fmt.Errorf("save state file: %v", err)) - slog.Error("not saving setup task state", "err", err, "task", key) - return - } - - err = os.MkdirAll(filepath.Dir(path), 0o755) - if err == nil { - err = os.WriteFile(path, data, 0o644) - } - if err != nil { - err = taskError(key, fmt.Errorf("save state file: %v", err)) - slog.Error("not saving setup task state", "err", err, "task", key) - return - } - - sudoUID, sudoGID := os.Getenv("SUDO_UID"), os.Getenv("SUDO_GID") - if sudoUID != "" || sudoGID != "" { - uid, err := strconv.Atoi(sudoUID) - if err != nil { - uid = -1 - } - gid, err := strconv.Atoi(sudoGID) - if err != nil { - gid = -1 - } - err = os.Chown(path, uid, gid) - if err != nil { - err = taskError(key, fmt.Errorf("chown state file to non-sudo user: %v", err)) - slog.Error("cannot ensure task state is owned by sudoing user", "err", err, "task", key, "uid", sudoUID, "gid", sudoGID) - } - } -} - -func statePath(key string) string { - dir := xdg.StateSubpath("devbox") - name := strings.ReplaceAll(key, "/", "-") - return filepath.Join(dir, name) -} - -func taskError(key string, err error) error { - if err == nil { - return nil - } - return redact.Errorf("setup: task %s: %w", key, err) -} - -// devboxExecutable returns the path to the Devbox launcher script or the -// current binary if the launcher is unavailable. -func devboxExecutable() (string, error) { - if exe := os.Getenv(envir.LauncherPath); exe != "" { - if abs, err := filepath.Abs(exe); err == nil { - return abs, nil - } - } - - exe, err := os.Executable() - if err != nil { - return "", redact.Errorf("get path to devbox executable: %v", err) - } - return exe, nil -} - -func isSudo(key string) bool { - // DEVBOX_SUDO_TASK is set when a task relaunched Devbox by calling - // SudoDevbox. If it matches the current task key, then the pre-sudo - // process is already running this task and we can skip checking - // task.NeedsRun and prompting the user. - envTask := os.Getenv("DEVBOX_SUDO_TASK") - return envTask != "" && envTask == key -} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go deleted file mode 100644 index 755819342f6..00000000000 --- a/internal/setup/setup_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package setup - -import ( - "context" - "errors" - "fmt" - "os" - "testing" -) - -type testTask struct { - RunFunc func(ctx context.Context) error - NeedsRunFunc func(ctx context.Context, lastRun RunInfo) bool -} - -func (t *testTask) Run(ctx context.Context) error { - return t.RunFunc(ctx) -} - -func (t *testTask) NeedsRun(ctx context.Context, lastRun RunInfo) bool { - return t.NeedsRunFunc(ctx, lastRun) -} - -func TestTaskNeedsRunTrue(t *testing.T) { - tempXDGStateDir(t) - - ran := false - task := &testTask{ - RunFunc: func(ctx context.Context) error { - ran = true - return nil - }, - NeedsRunFunc: func(context.Context, RunInfo) bool { - return true - }, - } - - err := Run(t.Context(), t.Name(), task) - if err != nil { - t.Error("got non-nil error:", err) - } - if !ran { - t.Error("got ran = false, want true") - } -} - -func TestTaskNeedsRunFalse(t *testing.T) { - tempXDGStateDir(t) - - ran := false - task := &testTask{ - RunFunc: func(ctx context.Context) error { - ran = true - return nil - }, - NeedsRunFunc: func(context.Context, RunInfo) bool { - return false - }, - } - - err := Run(t.Context(), t.Name(), task) - if err != nil { - t.Error("got non-nil error:", err) - } - if ran { - t.Error("got ran = true, want false") - } -} - -func TestTaskLastRun(t *testing.T) { - tempXDGStateDir(t) - - task := &testTask{ - RunFunc: func(ctx context.Context) error { return nil }, - NeedsRunFunc: func(context.Context, RunInfo) bool { return true }, - } - err := Run(t.Context(), t.Name(), task) - if err != nil { - t.Error("got non-nil error on first run:", err) - } - - task.NeedsRunFunc = func(ctx context.Context, lastRun RunInfo) bool { - if lastRun.Time.IsZero() { - t.Error("got zero lastRun.Time on second run") - } - if lastRun.Version == "" { - t.Error("got empty lastRun.Version on second run") - } - if lastRun.Error != "" { - t.Errorf("got non-empty lastRun.Error on second run: %v", lastRun.Error) - } - return false - } - err = Run(t.Context(), t.Name(), task) - if err != nil { - t.Error("got non-nil error on second run:", err) - } -} - -func TestTaskConfirmPromptAllow(t *testing.T) { - tempXDGStateDir(t) - - task := &testTask{ - RunFunc: func(ctx context.Context) error { return nil }, - NeedsRunFunc: func(context.Context, RunInfo) bool { return true }, - } - - setPromptResponse(t, true) - err := ConfirmRun(t.Context(), t.Name(), task, "continue?") - if err != nil { - t.Error("got non-nil error:", err) - } -} - -func TestTaskConfirmPromptDeny(t *testing.T) { - tempXDGStateDir(t) - - task := &testTask{ - RunFunc: func(ctx context.Context) error { return nil }, - NeedsRunFunc: func(context.Context, RunInfo) bool { return true }, - } - - setPromptResponse(t, false) - err := ConfirmRun(t.Context(), t.Name(), task, "continue?") - if err == nil { - t.Error("got nil error, want ErrUserRefused") - } else if !errors.Is(err, ErrUserRefused) { - t.Error("got errors.Is(err, ErrUserRefused) == false for error:", err) - } -} - -// TestSudoDevbox uses sudo on the current test binary to recursively call -// itself as root. This test can only be run manually (because it needs sudo) -// but is still useful for testing after making any changes to the sudo code. -// -// - Within the test we check if os.Getuid() == 0 to act differently depending -// on if we're the sudo test process or the parent (non-sudo) test process. -// - The sudo version of the test creates a "test-sudo-devbox-result" file. -// - The non-sudo version of the test looks for the same file to know if the -// sudo worked. -func TestSudoDevbox(t *testing.T) { - t.Skip("this test must be run manually because it requires sudo") - - ctx := t.Context() - key := "test-sudo-devbox" - resultFile := key + "-result" - - // Non-sudo process cleans up the result file. - os.Remove(resultFile) - t.Cleanup(func() { - if os.Getuid() != 0 { - os.Remove(resultFile) - } - }) - - task := &testTask{} - task.RunFunc = func(ctx context.Context) error { - ran, err := SudoDevbox(ctx, "-test.run", "^"+t.Name()+"$") - if ran || err != nil { - return err - } - - // Create a result file to indicate to the non-sudo process that - // we ran as root successfully. - if os.Getuid() == 0 { - return os.WriteFile(resultFile, nil, 0o666) - } - err = fmt.Errorf("task.NeedsRun not running as root after calling SudoDevbox") - t.Error(err) - return err - } - task.NeedsRunFunc = func(ctx context.Context, lastRun RunInfo) bool { - if os.Getuid() == 0 { - t.Error("task.NeedsRun called in sudo process, but should only be called in user process") - } - return true - } - - old := defaultPrompt - t.Cleanup(func() { defaultPrompt = old }) - defaultPrompt = func(msg string) (response any, err error) { - if os.Getuid() == 0 { - err = fmt.Errorf("user prompted again while already running as sudo") - t.Error(err) - return false, err - } - return true, nil - } - - err := ConfirmRun(ctx, key, task, "Allow sudo to run Devbox as root?") - if err != nil { - t.Error("got ConfirmRun error:", err) - } - if _, err := os.Stat(resultFile); err != nil { - t.Error("got missing sudo result file:", err) - } -} - -func tempXDGStateDir(t *testing.T) { - t.Helper() - t.Setenv("XDG_STATE_HOME", t.TempDir()) -} - -func setPromptResponse(t *testing.T, a any) { - old := defaultPrompt - t.Cleanup(func() { defaultPrompt = old }) - defaultPrompt = func(string) (any, error) { return a, nil } -} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 31c93ed90a8..41471aaff37 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -44,7 +44,6 @@ const ( EventShellInteractive EventShellReady EventNixBuildSuccess - EventNixBuildWithSubstitutersFailed ) var (