1
0
mirror of https://github.com/coreos/coreos-assembler.git synced 2026-02-05 09:44:53 +01:00

Make /usr/bin/coreos-assembler a Go program, implement clean in Go

- Converts the entrypoint into Go code
- Add an internal library that exposes/wraps `cmdlib.sh`
  because we have a lot of stuff in there that can't be ported
  to Go yet.
- Add an internal library for running inline (named) bash scripts
- Port `clean` to Go

This is a pattern I think we'll use to aid the transition; rather
than trying to rewrite things wholesale in Go, we'll continue
to exec some shell scripts.

Gradually perhaps, we may invert some things and change both
`cmdlib.sh` and `cmdlib.py` to exec the cosa Go process in some
cases too.

Closes: https://github.com/coreos/coreos-assembler/issues/2821
This commit is contained in:
Colin Walters
2022-06-14 10:09:38 -04:00
committed by Dusty Mabe
parent 6363853ea3
commit aa944b8f6c
10 changed files with 543 additions and 154 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ maipo/
.coverage
tools/bin
.idea
bin/

View File

@@ -13,7 +13,7 @@ PYIGNORE ?= E128,E241,E402,E501,E722,W503,W504
MANTLE_BINARIES := ore kola plume
all: tools mantle gangplank
all: bin/coreos-assembler tools mantle gangplank
src:=$(shell find src -maxdepth 1 -type f -executable -print)
pysources=$(shell find src -type f -name '*.py') $(shell for x in $(src); do if head -1 $$x | grep -q python; then echo $$x; fi; done)
@@ -30,12 +30,16 @@ else ifeq ($(GOARCH),aarch64)
GOARCH="arm64"
endif
bin/coreos-assembler:
cd cmd && go build -mod vendor -o ../$@
.PHONY: bin/coreos-assembler
.%.shellchecked: %
./tests/check_one.sh $< $@
shellcheck: ${src_checked} ${tests_checked} ${cwd_checked}
check: shellcheck flake8 pycheck schema-check mantle-check gangplank-check
check: shellcheck flake8 pycheck schema-check mantle-check gangplank-check cosa-go-check
echo OK
pycheck:
@@ -53,6 +57,11 @@ unittest:
COSA_TEST_META_PATH=`pwd`/fixtures \
PYTHONPATH=`pwd`/src python3 -m pytest tests/
cosa-go-check:
(cd cmd && go test -mod=vendor)
go test -mod=vendor github.com/coreos/coreos-assembler/internal/pkg/bashexec
go test -mod=vendor github.com/coreos/coreos-assembler/internal/pkg/cosash
clean:
rm -f ${src_checked} ${tests_checked} ${cwd_checked}
rm -rf tools/bin
@@ -111,7 +120,7 @@ install:
install -d $(DESTDIR)$(PREFIX)/lib/coreos-assembler/cosalib
install -D -t $(DESTDIR)$(PREFIX)/lib/coreos-assembler/cosalib $$(find src/cosalib/ -maxdepth 1 -type f)
install -d $(DESTDIR)$(PREFIX)/bin
ln -sf ../lib/coreos-assembler/coreos-assembler $(DESTDIR)$(PREFIX)/bin/
install bin/coreos-assembler $(DESTDIR)$(PREFIX)/bin/
ln -sf ../lib/coreos-assembler/cp-reflink $(DESTDIR)$(PREFIX)/bin/
ln -sf coreos-assembler $(DESTDIR)$(PREFIX)/bin/cosa
install -d $(DESTDIR)$(PREFIX)/lib/coreos-assembler/tests/kola

55
cmd/clean.go Normal file
View File

@@ -0,0 +1,55 @@
// See usage below
package main
import (
"fmt"
"github.com/coreos/coreos-assembler/internal/pkg/bashexec"
"github.com/coreos/coreos-assembler/internal/pkg/cosash"
)
func runClean(argv []string) error {
const cleanUsage = `Usage: coreos-assembler clean --help
coreos-assembler clean [--all]
Delete all build artifacts. Use --all to also clean the cache/ directory.
`
all := false
for _, arg := range argv {
switch arg {
case "h":
case "--help":
fmt.Print(cleanUsage)
return nil
case "-a":
case "--all":
all = true
default:
return fmt.Errorf("unrecognized option: %s", arg)
}
}
sh, err := cosash.NewCosaSh()
if err != nil {
return err
}
if _, err := sh.PrepareBuild(); err != nil {
return err
}
if all {
priv, err := sh.HasPrivileges()
if err != nil {
return err
}
cmd := "rm -rf cache/*"
if priv {
cmd = fmt.Sprintf("sudo %s", cmd)
}
bashexec.Run("cleanup cache", cmd)
} else {
fmt.Println("Note: retaining cache/")
}
return bashexec.Run("cleanup", "rm -rf builds/* tmp/*")
}

177
cmd/coreos-assembler.go Normal file
View File

@@ -0,0 +1,177 @@
// This is the primary entrypoint for /usr/bin/coreos-assembler.
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"sort"
"strings"
"syscall"
)
// commands we'd expect to use in the local dev path
var buildCommands = []string{"init", "fetch", "build", "run", "prune", "clean", "list"}
var advancedBuildCommands = []string{"buildfetch", "buildupload", "oc-adm-release", "push-container", "upload-oscontainer"}
var buildextendCommands = []string{"aliyun", "aws", "azure", "digitalocean", "exoscale", "gcp", "ibmcloud", "kubevirt", "live", "metal", "metal4k", "nutanix", "openstack", "qemu", "secex", "virtualbox", "vmware", "vultr"}
var utilityCommands = []string{"aws-replicate", "compress", "generate-hashlist", "koji-upload", "kola", "remote-build-container", "remote-prune", "sign", "tag"}
var otherCommands = []string{"shell", "meta"}
func init() {
// Note buildCommands is intentionally listed in frequency order
sort.Strings(advancedBuildCommands)
sort.Strings(buildextendCommands)
sort.Strings(utilityCommands)
sort.Strings(otherCommands)
}
func wrapCommandErr(err error) error {
if err == nil {
return nil
}
if exiterr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("%w\n%s", err, exiterr.Stderr)
}
return err
}
func printCommands(title string, cmds []string) {
fmt.Printf("%s:\n", title)
for _, cmd := range cmds {
fmt.Printf(" %s\n", cmd)
}
}
func printUsage() {
fmt.Println("Usage: coreos-assembler CMD ...")
printCommands("Build commands", buildCommands)
printCommands("Advanced build commands", advancedBuildCommands)
printCommands("Platform builds", buildextendCommands)
printCommands("Utility commands", utilityCommands)
printCommands("Other commands", otherCommands)
}
func run(argv []string) error {
if err := initializeGlobalState(argv); err != nil {
return fmt.Errorf("failed to initialize global state: %w", err)
}
var cmd string
if len(argv) > 0 {
cmd = argv[0]
argv = argv[1:]
}
if cmd == "" {
printUsage()
os.Exit(1)
}
// Manual argument parsing here for now; once we get to "phase 1"
// of the Go conversion we can vendor cobra (and other libraries)
// at the toplevel.
switch cmd {
case "clean":
return runClean(argv)
}
target := fmt.Sprintf("/usr/lib/coreos-assembler/cmd-%s", cmd)
_, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("unknown command: %s", cmd)
}
return fmt.Errorf("failed to stat %s: %w", target, err)
}
c := exec.Command(target, argv...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("failed to execute cmd-%s: %w", cmd, err)
}
return nil
}
func initializeGlobalState(argv []string) error {
// Set PYTHONUNBUFFERED=1 so that we get unbuffered output. We should
// be able to do this on the shebang lines but env doesn't support args
// right now. In Fedora we should be able to use the `env -S` option.
os.Setenv("PYTHONUNBUFFERED", "1")
// docker/podman don't run through PAM, but we want this set for the privileged
// (non-virtualized) path
user, ok := os.LookupEnv("USER")
if !ok {
b, err := exec.Command("id", "-nu").Output()
if err == nil {
user = strings.TrimSpace(string(b))
} else {
user = "cosa"
}
os.Setenv("USER", user)
}
// https://github.com/containers/libpod/issues/1448
// if /sys/fs/selinux is mounted, various tools will think they're on a SELinux enabled
// host system, and we don't want that. Work around this by overmounting it.
// So far we only see /sys/fs/selinux mounted in a privileged container, so we know we
// have privileges to create a new mount namespace and overmount it with an empty directory.
const selinuxfs = "/sys/fs/selinux"
if _, err := os.Stat(selinuxfs + "/status"); err == nil {
const unsharedKey = "coreos_assembler_unshared"
if _, ok := os.LookupEnv(unsharedKey); ok {
err := exec.Command("sudo", "mount", "--bind", "/usr/share/empty", "/sys/fs/selinux").Run()
if err != nil {
return fmt.Errorf("failed to unmount %s: %w", selinuxfs, wrapCommandErr(err))
}
} else {
fmt.Fprintf(os.Stderr, "warning: %s appears to be mounted but should not be; enabling workaround\n", selinuxfs)
selfpath, err := os.Readlink("/proc/self/exe")
if err != nil {
return err
}
baseArgv := []string{"sudo", "-E", "--", "env", fmt.Sprintf("%s=1", unsharedKey), "unshare", "-m", "--", "runuser", "-u", user, "--", selfpath}
err = syscall.Exec("/usr/bin/sudo", append(baseArgv, argv...), os.Environ())
return fmt.Errorf("failed to re-exec self to unmount %s: %w", selinuxfs, err)
}
}
// When trying to connect to libvirt we get "Failed to find user record
// for uid" errors if there is no entry for our UID in /etc/passwd.
// This was taken from 'Support Arbitrary User IDs' section of:
// https://docs.openshift.com/container-platform/3.10/creating_images/guidelines.html
c := exec.Command("whoami")
c.Stdout = ioutil.Discard
c.Stderr = ioutil.Discard
if err := c.Run(); err != nil {
fmt.Fprintln(os.Stderr, "notice: failed to look up uid in /etc/passwd; enabling workaround")
home := fmt.Sprintf("/var/tmp/%s", user)
err := os.MkdirAll(home, 0755)
if err != nil {
return err
}
f, err := os.OpenFile("/etc/passwd", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("opening /etc/passwd: %w", err)
}
defer f.Close()
id := os.Getuid()
buf := fmt.Sprintf("%s:x:%d:0:%s user:%s:/sbin/nologin\n", user, id, user, home)
if _, err = f.WriteString(buf); err != nil {
return err
}
}
return nil
}
func main() {
err := run(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/coreos/coreos-assembler
go 1.15

View File

@@ -0,0 +1,101 @@
// Package bashexec provides helpers to execute bash code.
// What this primarily offers over directly writing e.g. `exec.Command("bash")`
// is:
//
// - By default, all fragments are executed in "bash strict mode": http://redsymbol.net/articles/unofficial-bash-strict-mode/
// - The code encourages adding a "name" for in-memory scripts, similar to e.g.
// Ansible tasks as well as many CI systems like Github actions
// - The code to execute is piped to stdin instead of passed via `-c` which
// avoids argument length limits and makes the output of e.g. `ps` readable.
// - Scripts are assumed synchronous, and stdin/stdout/stderr are passed directly
// instead of piped.
// - We use prctl(PR_SET_PDEATHSIG) (assuming Linux) to lifecycle bind the script to the caller
//
package bashexec
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
)
// StrictMode enables http://redsymbol.net/articles/unofficial-bash-strict-mode/
const StrictMode = "set -euo pipefail"
// BashRunner is a wrapper for executing in-memory bash scripts
type BashRunner struct {
name string
cmd *exec.Cmd
}
// NewBashRunner creates a bash executor from in-memory shell script.
func NewBashRunner(name, src string, args ...string) (*BashRunner, error) {
// This will be proxied to fd 3
f, err := os.CreateTemp("", name)
if err != nil {
return nil, err
}
if _, err := io.Copy(f, strings.NewReader(src)); err != nil {
return nil, err
}
if err := os.Remove(f.Name()); err != nil {
return nil, err
}
bashCmd := fmt.Sprintf("%s\n. /proc/self/fd/3\n", StrictMode)
fullargs := append([]string{"-c", bashCmd, name}, args...)
cmd := exec.Command("/bin/bash", fullargs...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
}
cmd.Stdin = os.Stdin
cmd.ExtraFiles = append(cmd.ExtraFiles, f)
return &BashRunner{
name: name,
cmd: cmd,
}, nil
}
// Exec synchronously spawns the child process, passing stdin/stdout/stderr directly.
func (r *BashRunner) Exec() error {
r.cmd.Stdin = os.Stdin
r.cmd.Stdout = os.Stdout
r.cmd.Stderr = os.Stderr
err := r.cmd.Run()
if err != nil {
return fmt.Errorf("failed to execute internal script %s: %w", r.name, err)
}
return nil
}
// Run spawns the script, gathering stdout/stderr into a buffer that is displayed only on error.
func (r *BashRunner) Run() error {
buf, err := r.cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to execute internal script %s: %w\n%s", r.name, err, buf)
}
return nil
}
// Run spawns a named script (without any arguments),
// gathering stdout/stderr into a buffer that is displayed only on error.
func Run(name, cmd string) error {
sh, err := NewBashRunner(name, cmd)
if err != nil {
return err
}
return sh.Run()
}
// RunA spawns an anonymous script, and is otherwise the same as `Run`.
func RunA(cmd string) error {
sh, err := NewBashRunner("", cmd)
if err != nil {
return err
}
return sh.Run()
}

View File

@@ -0,0 +1,14 @@
package bashexec
import "testing"
func TestBashExec(t *testing.T) {
err := Run("true", "true")
if err != nil {
panic(err)
}
err = Run("task that should fail", "false")
if err == nil {
panic("expected err")
}
}

View File

@@ -0,0 +1,180 @@
// Package cosash implements a "co-processing" proxy that is primarily
// designed to expose a Go API that is currently implemented by `src/cmdlib.sh`.
// A lot of the code in that file is stateful - e.g. APIs set environment variables
// and allocate temporary directories. So it wouldn't work very well to fork
// a new shell process each time.
//
// The "co-processing" here is a way to describe that there's intended to be
// a one-to-one relationship of the child bash process and the current one,
// although this is not strictly required. The Go APIs here call dynamically
// into the bash process by writing to its stdin, and can receive serialized
// data back over a pipe on file descriptor 3.
package cosash
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"github.com/coreos/coreos-assembler/internal/pkg/bashexec"
)
// CosaSh is a companion shell process which accepts commands
// piped over stdin.
type CosaSh struct {
cmd *exec.Cmd
input io.WriteCloser
preparedBuild bool
ackserial uint64
replychan <-chan (string)
errchan <-chan (error)
}
func parseAck(r *bufio.Reader, expected uint64) (string, error) {
linebytes, _, err := r.ReadLine()
if err != nil {
return "", err
}
line := string(linebytes)
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid reply from cosash: %s", line)
}
serial, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
return "", fmt.Errorf("invalid reply from cosash: %s", line)
}
if serial != expected {
return "", fmt.Errorf("unexpected ack serial from cosash; expected=%d reply=%d", expected, serial)
}
return parts[1], nil
}
// NewCosaSh creates a new companion shell process
func NewCosaSh() (*CosaSh, error) {
cmd := exec.Command("/bin/bash")
cmd.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
}
// This is the channel where we send our commands
input, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
// stdout and stderr are the same as ours; we are effectively
// "co-processing", so we want to get output/errors as they're
// printed.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmdin, cmdout, err := os.Pipe()
if err != nil {
return nil, err
}
cmd.ExtraFiles = append(cmd.ExtraFiles, cmdout)
// Start the process
if err := cmd.Start(); err != nil {
return nil, err
}
replychan := make(chan string)
errchan := make(chan error)
r := &CosaSh{
input: input,
cmd: cmd,
replychan: replychan,
errchan: errchan,
preparedBuild: false,
}
// Send a message when the process exits
go func() {
errchan <- cmd.Wait()
}()
// Parse the ack serials into a channel
go func() {
bufr := bufio.NewReader(cmdin)
for {
reply, err := parseAck(bufr, r.ackserial)
if err != nil {
// Don't propagate EOF, since we want the process exit status instead.
if err == io.EOF {
break
}
errchan <- err
break
}
r.ackserial += 1
replychan <- reply
}
}()
// Initialize the internal library
err = r.process(fmt.Sprintf("%s\n. /usr/lib/coreos-assembler/cmdlib.sh\n", bashexec.StrictMode))
if err != nil {
return nil, fmt.Errorf("failed to init cosash: %w", err)
}
return r, nil
}
// write sends content to the shell's stdin, synchronously wait for the reply
func (r *CosaSh) processWithReply(buf string) (string, error) {
// Inject code which writes the serial reply prefix
cmd := fmt.Sprintf("echo -n \"%d \" >&3\n", r.ackserial)
if _, err := io.WriteString(r.input, cmd); err != nil {
return "", err
}
// Tell the shell to execute the code, which should write the reply to fd 3
// which will complete the command.
if _, err := io.WriteString(r.input, buf); err != nil {
return "", err
}
select {
case reply := <-r.replychan:
return reply, nil
case err := <-r.errchan:
return "", err
}
}
func (sh *CosaSh) process(buf string) error {
buf = fmt.Sprintf("%s\necho OK >&3\n", buf)
r, err := sh.processWithReply(buf)
if err != nil {
return err
}
if r != "OK" {
return fmt.Errorf("unexpected reply from cosash; expected OK, found %s", r)
}
return nil
}
// PrepareBuild prepares for a build, returning the newly allocated build directory
func (sh *CosaSh) PrepareBuild() (string, error) {
return sh.processWithReply(`prepare_build
pwd >&3
`)
}
// HasPrivileges checks if we can use sudo
func (sh *CosaSh) HasPrivileges() (bool, error) {
r, err := sh.processWithReply(`
if has_privileges; then
echo true >&3
else
echo false >&3
fi`)
if err != nil {
return false, err
}
return strconv.ParseBool(r)
}

View File

@@ -1,68 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
dn=$(dirname "$0")
# shellcheck source=src/cmdlib.sh
. "${dn}"/cmdlib.sh
print_help() {
cat 1>&2 <<'EOF'
Usage: coreos-assembler clean --help
coreos-assembler clean [--all]
Delete all build artifacts. Use --all to also clean the cache/ directory.
EOF
}
rc=0
all=0
options=$(getopt --options ah --longoptions all,help -- "$@") || rc=$?
[ $rc -eq 0 ] || {
print_help
exit 1
}
eval set -- "$options"
while true; do
case "$1" in
-h | --help)
print_help
exit 0
;;
-a | --all)
all=1
;;
--)
shift
break
;;
*)
fatal "$0: unrecognized option: $1"
exit 1
;;
esac
shift
done
if [ $# -ne 0 ]; then
print_help
fatal "ERROR: Too many arguments"
exit 1
fi
set -x
# This has some useful sanity checks
prepare_build
# But go back to the toplevel
cd "${workdir:?}"
# We don't clean the cache by default.
if test "${all}" = "1"; then
if has_privileges; then
sudo rm -rf cache/*
else
rm -rf cache/*
fi
else
echo "Note: retaining cache/"
fi
rm -rf builds/* tmp/*

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: coreos-assembler <cmd> ...
# Currently this just wraps the two binaries we have today
# under a global entrypoint with subcommands.
# Set PYTHONUNBUFFERED=1 so that we get unbuffered output. We should
# be able to do this on the shebang lines but env doesn't support args
# right now. In Fedora we should be able to use the `env -S` option.
export PYTHONUNBUFFERED=1
# docker/podman don't run through PAM, but we want this set for the privileged
# (non-virtualized) path
export USER="${USER:-$(id -nu)}"
# When trying to connect to libvirt we get "Failed to find user record
# for uid" errors if there is no entry for our UID in /etc/passwd.
# This was taken from 'Support Arbitrary User IDs' section of:
# https://docs.openshift.com/container-platform/3.10/creating_images/guidelines.html
if ! whoami &> /dev/null; then
# We need to make sure we set $HOME in the /etc/passwd file because
# if we don't libvirt will try to use `/` and we will get permission
# issues
export HOME="/var/tmp/${USER_NAME:-default}" && mkdir -p "$HOME"
if [ -w /etc/passwd ]; then
echo "${USER_NAME:-default}:x:$(id -u):0:${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd
fi
fi
# Ensure we've unshared our mount namespace so
# the later umount doesn't affect the host potentially
if [ -e /sys/fs/selinux/status ]; then
if [ -z "${coreos_assembler_unshared:-}" ]; then
exec sudo -E -- env coreos_assembler_unshared=1 unshare -m -- runuser -u "${USER}" -- "$0" "$@"
else
# Work around https://github.com/containers/libpod/issues/1448
# https://github.com/cgwalters/coretoolbox/blob/04e36894cdb912cd4d4c91b26436c57a2d96707d/src/coretoolbox.rs#L616
sudo mount --bind /usr/share/empty /sys/fs/selinux
fi
fi
cmd=${1:-}
# commands we'd expect to use in the local dev path
build_commands="init fetch build run prune clean list"
# commands more likely to be used in a prod pipeline only
advanced_build_commands="buildfetch buildupload oc-adm-release push-container upload-oscontainer"
buildextend_commands="aliyun aws azure digitalocean exoscale gcp ibmcloud kubevirt live metal metal4k nutanix openstack qemu secex virtualbox vmware vultr"
utility_commands="aws-replicate compress generate-hashlist koji-upload kola remote-build-container remote-prune sign tag"
other_commands="shell meta"
if [ -z "${cmd}" ]; then
echo Usage: "coreos-assembler CMD ..."
echo "Build commands:"
for bin in ${build_commands}; do
echo " ${bin}"
done # don't sort these ones, they're roughly in the order they're used
echo "Advanced build commands:"
for bin in ${advanced_build_commands}; do
echo " ${bin}"
done && for bin in ${buildextend_commands}; do
echo " buildextend-${bin}"
done | sort
echo "Utility commands:"
for bin in ${utility_commands}; do
echo " ${bin}"
done | sort
echo "Other commands:"
for bin in ${other_commands}; do
echo " ${bin}"
done | sort
exit 1
fi
shift
target=/usr/lib/coreos-assembler/cmd-${cmd}
if test -x "${target}"; then
exec "${target}" "$@"
fi
echo "Unknown command: ${cmd}" 1>&2
exit 1