Skip to content

Commit

Permalink
BusyBox/Alpine support (k0sproject#94)
Browse files Browse the repository at this point in the history
The BusyBox stat and touch binaries differ from their GNU counterparts
in some ways that are relevant to rig:

The stat binary won't accept the --printf flag, but it accepts the
-c flag in the same way GNU stat does. The -c command won't accept
backslash escapes, though. Hence fall back to the pipe character as
delimiter.

The touch binary won't parse time zones and sub-second precision times.
Accommodate for that by normalizing the time to UTC and only use sub-
second precision timestamps if it's not BusyBox touch. This will loose
precision, but that's probably better than failing completely.

Make localhost connections use the SHELL instead of hard-coding bash.
Make the rigfs helper shell script POSIX compatible.

Add an OS support package for Alpine that uses apk for package
installations.

Add a minimal footloose-compatible Dockerfile for Alpine 3.18 to the
tests and execute it as part of the CI.
  • Loading branch information
twz123 authored Aug 29, 2023
1 parent f508137 commit 96c2ea4
Show file tree
Hide file tree
Showing 10 changed files with 149 additions and 24 deletions.
17 changes: 16 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,15 @@ jobs:

integration:
strategy:
fail-fast: false
matrix:
image:
- quay.io/footloose/ubuntu18.04
- quay.io/footloose/centos7
# - quay.io/footloose/amazonlinux2 ( not recognized )
- quay.io/footloose/debian10
# - quay.io/footloose/fedora29 ( not recognized )
- alpine-3.18.iid
needs: build
runs-on: ubuntu-20.04
steps:
Expand All @@ -57,8 +59,21 @@ jobs:
sudo apt-get update
sudo apt-get install expect
- name: Prepare footloose image
id: prepare-footloose-image
env:
IMAGE: ${{ matrix.image }}
run: |
case "$IMAGE" in
*.iid)
make -C test "$IMAGE"
IMAGE="$(cat "test/$IMAGE")"
;;
esac
echo image="$IMAGE" >> $GITHUB_OUTPUT
- name: Run integration tests
env:
LINUX_IMAGE: ${{ matrix.image }}
LINUX_IMAGE: ${{ steps.prepare-footloose-image.outputs.image }}
run: make -C test test

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ test/rigtest
test/footloose.yaml
test/Library
test/.ssh
test/*.iid
22 changes: 18 additions & 4 deletions cmd/rigtest/rigtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
_ "github.com/k0sproject/rig/os/support"
"github.com/k0sproject/rig/pkg/rigfs"
"github.com/kevinburke/ssh_config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -59,6 +60,7 @@ type configurer interface {
FileExist(os.Host, string) bool
DeleteFile(os.Host, string) error
Stat(os.Host, string, ...exec.Option) (*os.FileInfo, error)
Touch(os.Host, string, time.Time, ...exec.Option) error
MkDir(os.Host, string, ...exec.Option) error
}

Expand Down Expand Up @@ -218,6 +220,22 @@ func main() {

t.Run("os support module functions on %s", h)

stat, err := h.Configurer.Stat(h, fn)
require.Error(t, err, "no stat error")

now := time.Now()
err = h.Configurer.Touch(h, fn, now)
require.NoError(t, err, "touch error")

stat, err = h.Configurer.Stat(h, fn)
require.NoError(t, err, "stat error")
assert.Equal(t, filepath.Base(stat.Name()), filepath.Base(fn), "stat name not as expected")
assert.Equal(t, filepath.Base(stat.Name()), filepath.Base(fn), "stat name not as expected")
assert.Condition(t, func() bool {
actual := stat.ModTime()
return now.Equal(actual) || now.Truncate(time.Second).Equal(actual)
}, "Expected %s, got %s", now, stat.ModTime())

require.NoError(t, h.Configurer.WriteFile(h, fn, "test\ntest2\ntest3", "0644"), "write file")
if !h.Configurer.FileExist(h, fn) {
t.Fail("file does not exist after write")
Expand All @@ -228,10 +246,6 @@ func main() {
require.NoError(t, err, "read file")
require.Equal(t, "test\ntest4\ntest3", row, "file content not as expected after line into file")

stat, err := h.Configurer.Stat(h, fn)
require.NoError(t, err, "stat error")
require.Equal(t, filepath.Base(stat.Name()), filepath.Base(fn), "stat name not as expected")

require.NoError(t, h.Configurer.DeleteFile(h, fn))
require.False(t, h.Configurer.FileExist(h, fn))

Expand Down
2 changes: 1 addition & 1 deletion localhost.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func (c *Localhost) command(cmd string, o *exec.Options) (*osexec.Cmd, error) {
return osexec.Command("cmd.exe", "/c", cmd), nil
}

return osexec.Command("bash", "-c", "--", cmd), nil
return osexec.Command("sh", "-c", "--", cmd), nil
}

// ExecInteractive executes a command on the host and copies stdin/stdout/stderr from local host
Expand Down
34 changes: 30 additions & 4 deletions os/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,16 +406,26 @@ func (c Linux) Chmod(h Host, s, perm string, opts ...exec.Option) error {
// coreutils. Note that this is different from BSD coreutils.
const gnuCoreutilsDateTimeLayout = "2006-01-02 15:04:05.999999999 -0700"

// busyboxDateTimeLayout represents the date and time format that can be passed
// to BusyBox. BusyBox will happily output the GNU format, but will fail to
// parse it. Currently, there doesn't seem to be a way to support sub-second
// precision.
const busyboxDateTimeLayout = "2006-01-02 15:04:05"

// Stat gets file / directory information
func (c Linux) Stat(h Host, path string, opts ...exec.Option) (*FileInfo, error) {
cmd := `env -i LC_ALL=C stat --printf '%s\0%y\0%a\0%F' -- ` + shellescape.Quote(path)
cmd := `env -i LC_ALL=C stat -c '%s|%y|%a|%F' -- ` + shellescape.Quote(path)

out, err := h.ExecOutput(cmd, opts...)
if err != nil {
return nil, fmt.Errorf("failed to stat %s: %w", path, err)
}

fields := strings.SplitN(out, "\x00", 4)
fields := strings.SplitN(out, "|", 4)
if len(fields) != 4 {
err = fmt.Errorf("failed to stat %s: unrecognized output: %s", path, out) //nolint:goerr113
return nil, err
}

size, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
Expand Down Expand Up @@ -444,8 +454,24 @@ func (c Linux) Stat(h Host, path string, opts ...exec.Option) (*FileInfo, error)
// Touch updates a file's last modified time. It creates a new empty file if it
// didn't exist prior to the call to Touch.
func (c Linux) Touch(h Host, path string, ts time.Time, opts ...exec.Option) error {
cmd := fmt.Sprintf("env -i LC_ALL=C touch -m -d %s -- %s",
shellescape.Quote(ts.Format(gnuCoreutilsDateTimeLayout)),
utc := ts.UTC()

// The BusyBox format will be accepted by both BusyBox and GNU stat.
format := busyboxDateTimeLayout

// Sub-second precision in timestamps is supported by GNU, but not by
// BusyBox. If there is sub-second precision in the provided timestamp, try
// to detect BusyBox touch and if it's not BusyBox go on with the
// full-precision GNU format instead.
if !utc.Equal(utc.Truncate(time.Second)) {
out, err := h.ExecOutput("env -i LC_ALL=C TZ=UTC touch --help 2>&1")
if err != nil || !strings.Contains(out, "BusyBox") {
format = gnuCoreutilsDateTimeLayout
}
}

cmd := fmt.Sprintf("env -i LC_ALL=C TZ=UTC touch -m -d %s -- %s",
shellescape.Quote(utc.Format(format)),
shellescape.Quote(path),
)

Expand Down
52 changes: 52 additions & 0 deletions os/linux/alpine.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package linux

import (
"fmt"
"strings"

"github.com/alessio/shellescape"
"github.com/k0sproject/rig"
"github.com/k0sproject/rig/exec"
"github.com/k0sproject/rig/os"
"github.com/k0sproject/rig/os/registry"
)

// Alpine provides OS support for Alpine Linux.
type Alpine struct {
os.Linux
}

func init() {
registry.RegisterOSModule(
func(os rig.OSVersion) bool {
return os.ID == "alpine"
},
func() interface{} {
return &Alpine{}
},
)
}

// InstallPackage installs packages via apk.
func (l Alpine) InstallPackage(host os.Host, pkgs ...string) error {
if err := host.Execf("apk update", exec.Sudo(host)); err != nil {
return fmt.Errorf("failed to update apk cache: %w", err)
}

if len(pkgs) < 1 {
return nil
}

var cmd strings.Builder
cmd.WriteString("apk add --")
for _, pkg := range pkgs {
cmd.WriteRune(' ')
cmd.WriteString(shellescape.Quote(pkg))
}

if err := host.Exec(cmd.String(), exec.Sudo(host)); err != nil {
return fmt.Errorf("failed to install apk packages: %w", err)
}

return nil
}
4 changes: 2 additions & 2 deletions pkg/rigfs/posixfsys.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// rigHelper is a helper script to avoid having to write complex bash oneliners in Go
// it's not a read-loop "daemon" like the windows counterpart rigrcp.ps1
//
//go:embed righelper.bash
//go:embed righelper.sh
var rigHelper string

var (
Expand Down Expand Up @@ -278,7 +278,7 @@ func (fsys *PosixFsys) helper(args ...string) (*helperResponse, error) {
var res helperResponse
opts := fsys.opts
opts = append(opts, exec.Stdin(rigHelper))
out, err := fsys.conn.ExecOutput(fmt.Sprintf("bash -s -- %s", shellescape.QuoteCommand(args)), opts...)
out, err := fsys.conn.ExecOutput(fmt.Sprintf("sh -s -- %s", shellescape.QuoteCommand(args)), opts...)
if err != nil {
return nil, fmt.Errorf("%w: failed to execute helper: %w", ErrCommandFailed, err)
}
Expand Down
23 changes: 12 additions & 11 deletions pkg/rigfs/righelper.bash → pkg/rigfs/righelper.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env bash

shopt -s dotglob
#!/usr/bin/env sh
#shellcheck disable=SC3037,SC3043

abs() (
if [ -d "$1" ]; then
Expand All @@ -26,12 +25,12 @@ statjson() {
if [ "$path" = "" ]; then
throw "empty path"
fi
if stat --help 2>&1 | grep -q -- --format; then
# GNU stat
file_info=$(stat --format="0%a %s %Y" "$path" 2> /dev/null)
if stat --help 2>&1 | grep -q -- 'GNU\|BusyBox'; then
# GNU/BusyBox stat
file_info=$(stat -c '0%a %s %Y' -- "$path" 2> /dev/null)
else
# BSD stat
file_info=$(stat -f "%Mp%Lp %z %m" "$path" 2> /dev/null)
file_info=$(stat -f '%Mp%Lp %z %m' "$path" 2> /dev/null)
fi
read -r unix_mode size mod_time <<EOF
$file_info
Expand All @@ -44,15 +43,15 @@ EOF
else
is_dir=false
fi
if [ "$embed" == "" ]; then
if [ -z "$embed" ]; then
echo -n "{\"stat\":{\"size\":$size,\"unixMode\":$unix_mode,\"modTime\":$mod_time,\"isDir\":$is_dir,\"name\":\"$path\"}}"
else
echo -n "{\"size\":$size,\"unixMode\":$unix_mode,\"modTime\":$mod_time,\"isDir\":$is_dir,\"name\":\"$path\"}"
fi
}

throw() {
echo -n "{\"error\":\"$@\"}"
echo -n "{\"error\":\"$*\"}"
exit 1
}

Expand Down Expand Up @@ -88,7 +87,8 @@ main() {
fi
echo -n "{\"dir\":["
first=true
for file in "$path"/*; do
for file in "$path"/.* "$path"/*; do
case "$file" in "$path"/. | "$path"/..) continue ;; esac
if [ "$first" = true ]; then
first=false
else
Expand All @@ -113,4 +113,5 @@ main() {
;;
esac
}
main $@

main "$@"
13 changes: 13 additions & 0 deletions test/Dockerfile.alpine-3.18
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM docker.io/library/alpine:3.18.3

RUN apk add --no-cache \
alpine-base \
openssh-server \
bash \
&& rc-update add syslog boot \
&& rc-update add sshd default \
&& rc-update add local default \
# disable ttys
&& sed -i -e 's/^\(tty[0-9]\)/# \1/' /etc/inittab \
# no greetings
&& truncate -c -s0 /etc/issue /etc/motd
5 changes: 4 additions & 1 deletion test/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ footloose.yaml: .ssh/identity $(footloose)
--override \
--replicas $(REPLICAS)

%.iid: Dockerfile.%
docker build --iidfile '$@' - < '$<'

.PHONY: create-host
create-host: footloose.yaml docker-network
$(footloose) create -c footloose.yaml
Expand All @@ -63,7 +66,7 @@ delete-host: footloose.yaml
.PHONY: clean
clean: delete-host
rm -f footloose.yaml identity rigtest
rm -rf .ssh
rm -rf .ssh *.iid
docker network rm footloose-cluster || true

.PHONY: sshport
Expand Down

0 comments on commit 96c2ea4

Please sign in to comment.