Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ The installer is configurable using the following environment variables:
* `INSTALL_METHOD` - force a specific installation method, possible values are `brew` and `raw`
* `INSTALL_DIR` - the installation directory for the `raw` installation method, for example you can use `INSTALL_DIR=$HOME/.local/bin` for a single user installation
* `VERSION` - the version of the CLI to install, if you need a version other than the latest one
* `CONFIG_URL` - the URL of an alternative CLI configuration, installed after the CLI with `upsun config:install`
* `CONFIG_URL` - the URL of an alternative CLI configuration, installed after the CLI with `upsun config:install --force`. Its executable is put next to `upsun` if that directory is writable (except with Homebrew). As root, outside the home directory, the configuration is put in `/etc/upsun/platform-alt` so that other users can run it; only root updates it

The `raw` method installs in `/usr/bin` (`/usr/local/bin` on macOS), or in `~/.global/bin` or `~/.local/bin` if either is in the `PATH`. It uses `sudo` if needed, or `~/.local/bin` if `sudo` is not available.

Outside CI, the `raw` method also installs shell completion for the current user, without editing shell configuration files: bash completion in `~/.local/share/bash-completion/completions` (loaded by bash-completion 2), and fish completion in `~/.config/fish/completions` when available. For zsh users (by `$SHELL`), it installs `~/.local/share/upsun/completion.zsh` and prints the line to add to `~/.zshrc`.

Expand Down
2 changes: 1 addition & 1 deletion commands/config_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func pathFormatter() func(string) string {
}
hd, err := os.UserHomeDir()
return func(p string) string {
if err == nil && strings.HasPrefix(p, hd) {
if err == nil && (p == hd || strings.HasPrefix(p, hd+string(filepath.Separator))) {
return sub + strings.TrimPrefix(p, hd)
}
return p
Expand Down
23 changes: 23 additions & 0 deletions commands/config_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -129,3 +130,25 @@ func TestFindExecutableTarget(t *testing.T) {
assert.Equal(t, other, findExecutableTarget("test-cli-other"))
assert.Equal(t, self, findExecutableTarget("test-cli-missing"))
}

func TestPathFormatter(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses Unix paths")
}
cases := []struct {
home string
path string
want string
}{
{"/home/u", "/home/u/.local/bin", "~/.local/bin"},
{"/home/u", "/home/u", "~"},
{"/home/u", "/home/user/bin", "/home/user/bin"},
{"/", "/usr/bin", "/usr/bin"},
}
for _, c := range cases {
t.Run(c.home+" "+c.path, func(t *testing.T) {
t.Setenv("HOME", c.home)
assert.Equal(t, c.want, pathFormatter()(c.path))
})
}
}
69 changes: 66 additions & 3 deletions installer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,12 @@ call_try_user() {
}

call_user() {
sh -c "$1" 2>&1 | indent
# The status is written to a file, as a pipeline returns the status of its last command.
call_user_status=$(mktemp)
{ sh -c "$1" 2>&1; echo $? > "$call_user_status"; } | indent
call_user_code=$(cat "$call_user_status")
rm -f "$call_user_status"
return "$call_user_code"
}

check_shasum() {
Expand All @@ -469,6 +474,19 @@ check_directories() {
dir_bin="$HOME/.local/bin"
fi

# Without sudo, install in a user directory if the system one is not writable.
case "${dir_bin}" in
"${HOME:-}"/*) ;;
*)
if [ -z "${INSTALL_DIR}" ] && [ ! -w "${dir_bin}" ] && [ -n "${HOME:-}" ]; then
init_sudo
if ! ${has_sudo}; then
dir_bin="$HOME/.local/bin"
fi
fi
;;
esac

if ! echo $PATH | grep ${dir_bin} > /dev/null; then
run_cmd="${dir_bin}/$binary"

Expand Down Expand Up @@ -712,18 +730,63 @@ install_config() {
cmd="$binary"
if [ "raw" = "${INSTALL_METHOD}" ]; then
cmd="${dir_bin}/${binary}"
else
cmd=$(command -v "$binary" || echo "$binary")
fi

# The installer is explicitly installing this config, so it replaces executables with the same name.
set -- -y --force
# Put the new executable next to the CLI, which is on the PATH (or a warning says so).
# Homebrew's directory is left to Homebrew.
# Symlinks are resolved, as --bin-dir only accepts a directory.
cmd_dir=$(cd "$(dirname "$cmd")" && pwd -P)
if [ "homebrew" != "${INSTALL_METHOD}" ] && [ -w "$cmd_dir" ]; then
set -- "$@" --bin-dir "$cmd_dir"
# Share a root install with other users, as root's home is usually private.
case "$cmd_dir" in
"${HOME:-/root}"/*) ;;
*)
if [ "$(id -u)" = "0" ] && mkdir -p /etc/upsun/platform-alt \
&& chmod 755 /etc/upsun /etc/upsun/platform-alt; then
set -- "$@" --config-dir /etc/upsun/platform-alt
umask 022
fi
;;
esac
fi

output_file=$(mktemp)
if "$cmd" config:install -y "${CONFIG_URL}" </dev/null >"$output_file" 2>&1; then
if "$cmd" config:install "$@" "${CONFIG_URL}" </dev/null >"$output_file" 2>&1; then
indent < "$output_file"
rm -f "$output_file"
else
indent < "$output_file"
rm -f "$output_file"
output " could not install the configuration from ${CONFIG_URL}" "error"
exit_with_error
fi

# Read the new executable's path from the output, expanding "~".
exe_path=$(sed -n 's/^ Executable: //p' "$output_file" | head -n 1)
rm -f "$output_file"
case "$exe_path" in
"~"/*) exe_path="${HOME}${exe_path#"~"}" ;;
Comment thread
upsun-dispatch[bot] marked this conversation as resolved.
esac
if [ -z "$exe_path" ]; then
return
fi

exe_name=$(basename "$exe_path")
exe_found=$(command -v "$exe_name" || true)
if [ -n "$exe_found" ] && [ "$exe_found" -ef "$exe_path" ]; then
run_cmd="$exe_name"
return
fi
run_cmd="$exe_path"
if [ -n "$exe_found" ]; then
add_footer_note " ⚠ Another \"${exe_name}\" command comes first in \$PATH: ${exe_found}"
elif [ ! "$(dirname "$exe_path")" -ef "${dir_bin}" ]; then
add_footer_note " ⚠ The directory \"$(dirname "$exe_path")\" is not in \$PATH"
fi
}

install() {
Expand Down
5 changes: 4 additions & 1 deletion internal/config/alt/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func isExistingDirectory(path string) (bool, error) {
return stat.IsDir(), nil
}

// writeFile creates or overwrites a file.
// writeFile creates or overwrites a file, with fileMode regardless of the umask.
// If dirMode is not 0, the containing directory will be created, if it does not already exist.
func writeFile(path string, content []byte, dirMode, fileMode fs.FileMode) error {
if dirMode != 0 {
Expand All @@ -178,6 +178,9 @@ func writeFile(path string, content []byte, dirMode, fileMode fs.FileMode) error
if err := os.WriteFile(tmpFile, content, fileMode); err != nil {
return err
}
if err := os.Chmod(tmpFile, fileMode); err != nil {
return err
}

return os.Rename(tmpFile, path)
}
9 changes: 8 additions & 1 deletion internal/config/alt/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"time"

"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -59,6 +60,12 @@ func Update(ctx context.Context, cnf *config.Config, debugLog func(fmt string, i
}
}()

// A shared config, e.g. installed by root, is left to its owner.
if dir := filepath.Dir(cnf.SourceFile); !isWritableDir(dir) {
debugLog("Config directory is not writable, skipping update: %s", dir)
return nil
}

debugLog("Checking for config updates from URL: %s", cnf.Metadata.URL)
newCnfNode, newCnfStruct, err := FetchConfig(ctx, cnf.Metadata.URL)
if err != nil {
Expand Down Expand Up @@ -91,7 +98,7 @@ func Update(ctx context.Context, cnf *config.Config, debugLog func(fmt string, i
return err
}

if err := writeFile(cnf.SourceFile, b, 0, 0o644); err != nil {
if err := writeFile(cnf.SourceFile, b, 0, stat.Mode().Perm()); err != nil {
return err
}
debugLog("Automatically updated config file: %s", cnf.SourceFile)
Expand Down
38 changes: 38 additions & 0 deletions internal/config/alt/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
"time"
Expand Down Expand Up @@ -215,3 +216,40 @@ func TestUpdateWithUnusableNewVersion(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, string(localConfig), string(b), "the local config must not be modified")
}

// TestUpdateWithUnwritableDir checks that a shared config, e.g. installed by root, is not updated by other users.
func TestUpdateWithUnwritableDir(t *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
t.Skip("needs Unix permissions and a non-root user")
}
tempDir := t.TempDir()
configDir := filepath.Join(tempDir, "etc")
require.NoError(t, os.Mkdir(configDir, 0o755))
testConfigFilename := filepath.Join(configDir, "config.yaml")
require.NoError(t, os.WriteFile(testConfigFilename, testConfig, 0o600))
hourAgo := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(testConfigFilename, hourAgo, hourAgo))
require.NoError(t, os.Chmod(configDir, 0o555))
t.Cleanup(func() { _ = os.Chmod(configDir, 0o755) })

cnf, err := config.FromYAML(testConfig)
require.NoError(t, err)
t.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir)

requested := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requested = true
_, _ = w.Write(testConfigWithMetadata("{version: 1.0.1}"))
}))
defer server.Close()

cnf.SourceFile = testConfigFilename
cnf.Metadata.URL = server.URL + "/config.yaml"

var lastLogged string
err = alt.Update(config.ToContext(context.Background(), cnf), cnf,
func(msg string, args ...any) { lastLogged = fmt.Sprintf(msg, args...) })
assert.NoError(t, err)
assert.False(t, requested)
assert.Contains(t, lastLogged, "not writable")
}
50 changes: 50 additions & 0 deletions internal/config/alt/update_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//go:build unix

package alt_test

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"syscall"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/config/alt"
)

// TestUpdateKeepsMode checks that an update keeps a shared config readable under a restrictive umask.
func TestUpdateKeepsMode(t *testing.T) {
tempDir := t.TempDir()
testConfigFilename := filepath.Join(tempDir, "config.yaml")
require.NoError(t, os.WriteFile(testConfigFilename, testConfig, 0o600))
require.NoError(t, os.Chmod(testConfigFilename, 0o644))
hourAgo := time.Now().Add(-time.Hour)
require.NoError(t, os.Chtimes(testConfigFilename, hourAgo, hourAgo))

cnf, err := config.FromYAML(testConfig)
require.NoError(t, err)
t.Setenv(cnf.Application.EnvPrefix+"HOME", tempDir)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(testConfigWithMetadata("{version: 1.0.1}"))
}))
defer server.Close()
cnf.SourceFile = testConfigFilename
cnf.Metadata.URL = server.URL + "/config.yaml"

oldUmask := syscall.Umask(0o077)
defer syscall.Umask(oldUmask)

require.NoError(t, alt.Update(config.ToContext(context.Background(), cnf), cnf, func(string, ...any) {}))

stat, err := os.Stat(testConfigFilename)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o644), stat.Mode().Perm())
}
Loading
Loading