You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

561 lines
12 KiB
Go

package main
import (
"bufio"
"bytes"
_ "embed"
"errors"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/user"
"path"
"runtime"
"strconv"
"strings"
"syscall"
"text/template"
)
const usage = `workspace - Use a containerized workspace
COMMANDS
dump-init-files: Dump emacs/bash init files to home directory, overwriting existing files.
List of files: ~/.bash_aliases, ~/.emacs, ~/.custom.el, ~/.gitconfig, ~/.gitignore
toggle-nightlight: Toggle Gnome display nightlight feature.
`
//go:embed emacs/init.el
var initLisp string
//go:embed emacs/custom.el
var customLisp string
//go:embed emacs/epaper-theme.el
var elispEpaperTheme string
//go:embed emacs/emacs.desktop
var emacsDesktop string
//go:embed emacs/emacs.png
var emacsIcon []byte
//go:embed bash/bash_aliases
var bashAliases string
//go:embed git/config
var gitConfig string
//go:embed config/pantalaimon/pantalaimon.conf
var pantalaimonConfig string
//go:embed git/ignore
var gitIgnore string
var ErrAddUserFailExit = errors.New("useradd command returned no-zero exit code")
var ErrAddGroupFailExit = errors.New("groupadd command returned no-zero exit code")
func AddUser(username string, uid string) error {
commandArgs := []string{
"--uid", uid,
"--user-group",
"--shell", "/bin/bash",
username,
}
if os.Getenv("WORKSPACE_OS") == "darwin" {
commandArgs = append(commandArgs, "--create-home")
} else {
commandArgs = append(commandArgs, "--no-create-home")
}
cmd := exec.Command("useradd", commandArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(
"Error adding user: %w. Process error: %w. Process output: %s",
ErrAddUserFailExit,
err,
output,
)
}
return nil
}
func AddGroup(name string, gid string) error {
_, err := user.LookupGroupId(gid)
if err != nil {
if _, ok := err.(user.UnknownGroupIdError); ok {
cmd := exec.Command(
"groupadd",
"--gid", gid,
name,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf(
"Error adding group: %w. Process error: %s. Process output: %s",
ErrAddGroupFailExit,
err,
output,
)
}
return nil
}
log.Println(err)
}
return err
}
const osDarwin = "darwin"
const osLinux = "linux"
func EntryPoint(command []string) error {
hostOS := os.Getenv("WORKSPACE_OS")
if hostOS != osLinux {
log.Println(fmt.Sprintf("Note: Workspce OS is \"%s\"", hostOS))
}
if len(command) == 0 {
command = []string{"/bin/bash", "-i"}
}
uidString := os.Getenv("WORKSPACE_USER")
uid, err := strconv.Atoi(uidString)
if err != nil {
return fmt.Errorf(
"Failed to parse UID from WORKSPACE_USER env var with contents \"%s\". Error: %w",
os.Getenv("WORKSPACE_USER"),
err,
)
}
dockerGidString := os.Getenv("WORKSPACE_DOCKER_GID")
dockerGid, err := strconv.Atoi(dockerGidString)
if err != nil {
return fmt.Errorf(
"Failed to parse GID from WORKSPACE_DOCKER_GID env var with contents \"%s\". Error: %w",
os.Getenv("WORKSPACE_DOCKER_GID"),
err,
)
}
if uid != 0 {
err = AddUser(os.Getenv("WORKSPACE_USERNAME"), uidString)
if err != nil {
return err
}
err = AddGroup("docker", dockerGidString)
if err != nil {
return err
}
err = syscall.Setgroups([]int{dockerGid})
if err != nil {
return err
}
err = syscall.Setgid(int(uid))
if err != nil {
return err
}
err = syscall.Setuid(int(uid))
if err != nil {
return err
}
}
SetEnvVars()
path, err := exec.LookPath(command[0])
if err != nil {
return err
}
return syscall.Exec(path, command, os.Environ())
}
func Run(privileged bool, detach bool, mounts []string, command []string) error {
workDir, err := os.Getwd()
if err != nil {
return err
}
curUser, err := user.Current()
if err != nil {
return err
}
home := os.Getenv("HOME")
hostOS := runtime.GOOS
dockerBin, err := exec.LookPath("docker")
if err != nil {
if _, err := os.Stat("/usr/bin/docker"); !errors.Is(err, os.ErrNotExist) {
dockerBin = "/usr/bin/docker"
} else {
return err
}
}
dockerCommand := []string{
dockerBin, "run", "--network=host",
"--workdir=" + workDir,
"--rm",
"-e", "WORKSPACE_OS=" + hostOS,
"-v", "workspace-nodejs:/opt/nodejs",
}
if hostOS != "darwin" {
dockerGroup, err := user.LookupGroup("docker")
if err != nil {
return err
}
sshAuthSock := os.Getenv("SSH_AUTH_SOCK")
if sshAuthSock == "" {
authSockGuess := "/run/user/" + curUser.Uid + "/keyring/ssh"
if FileExists(authSockGuess) {
sshAuthSock = authSockGuess
}
}
xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR")
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
if waylandDisplay != "" {
// let's be a little forceful with wayland use if it's available
dockerCommand = append(dockerCommand,
"-e", "GDK_BACKEND=wayland",
"-e", "WAYLAND_DISPLAY="+waylandDisplay,
)
}
if xdgRuntimeDir != "" {
dockerCommand = append(dockerCommand,
"--mount", "type=bind,source="+xdgRuntimeDir+",target="+xdgRuntimeDir,
"-e", "XDG_RUNTIME_DIR="+xdgRuntimeDir,
)
}
dockerCommand = append(
dockerCommand,
"-e", "WORKSPACE_DOCKER_GID="+dockerGroup.Gid,
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "/etc/hosts:/etc/hosts:ro",
"-v", "/etc/resolv.conf:/etc/resolv.conf:ro",
"-v", home+":"+home,
"-e", "SSH_AGENT_LAUNCHER="+os.Getenv("SSH_AGENT_LAUNCHER"),
"-e", "SSH_AUTH_SOCK="+sshAuthSock,
"-e", "XDG_CURRENT_DESKTOP="+os.Getenv("XDG_CURRENT_DESKTOP"),
"-e", "DESKTOP_SESSION="+os.Getenv("DESKTOP_SESSION"),
"-e", "PULSE_SERVER=unix:/run/user/"+curUser.Uid+"/pulse/native",
"-e", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/"+curUser.Uid+"/bus",
"-v", "/tmp/.X11-unix:/tmp/.X11-unix",
"-v", "/dev/snd:/dev/snd",
"-e", "DISPLAY="+os.Getenv("DISPLAY"),
"-e", "XAUTHORITY="+os.Getenv("XAUTHORITY"),
"-e", "WORKSPACE_USER="+curUser.Uid,
"-e", "WORKSPACE_USERNAME="+curUser.Username,
"-e", "HOME="+home,
"-h", os.Getenv("HOSTNAME"),
"-e", "TERM="+os.Getenv("TERM"),
)
fileInfo, err := os.Lstat(home)
if err != nil {
return fmt.Errorf(
"Failed to determine whether home directory is a symbolic link: %w",
err,
)
}
if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
destination, err := os.Readlink(home)
if err != nil {
return fmt.Errorf(
"Failed to determine whether home directory is a symbolic link: %w",
err,
)
}
dockerCommand = append(dockerCommand, "-v", destination+":"+destination)
}
} else {
dockerCommand = append(
dockerCommand,
"-e", "WORKSPACE_DOCKER_GID=1001",
"-v", workDir+":"+workDir,
"-e", "WORKSPACE_USER=1000",
"-e", "WORKSPACE_USERNAME="+curUser.Username+"-workspace",
"-e", "HOME=/home/"+curUser.Username+"-workspace",
)
}
if privileged {
dockerCommand = append(dockerCommand, "--privileged")
}
if detach {
dockerCommand = append(dockerCommand, "--detach")
}
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
dockerCommand = append(dockerCommand, "-ti")
}
for _, mount := range mounts {
dockerCommand = append(dockerCommand, "-v", mount+":"+mount)
}
dockerCommand = append(dockerCommand, "git.tar.cx/hugo/workspace:latest")
dockerCommand = append(dockerCommand, command...)
return syscall.Exec(dockerCommand[0], dockerCommand, os.Environ())
}
func SetEnvVars() {
home := os.Getenv("HOME")
os.Setenv("PATH", home+"/bin:"+os.Getenv("PATH"))
os.Setenv("PATH", home+"/dotfiles/bin:"+os.Getenv("PATH"))
// PHP
os.Setenv("PATH", home+"/.config/composer/vendor/bin:"+os.Getenv("PATH"))
// Node
os.Setenv("PATH", home+"/.npm_packages/bin:"+os.Getenv("PATH"))
os.Setenv("PATH", "/opt/nodejs/bin:"+os.Getenv("PATH"))
// Perl
os.Setenv("PATH", home+"/perl5/perlbrew/bin:"+os.Getenv("PATH"))
// Rust
os.Setenv("PATH", home+"/.cargo/bin:"+os.Getenv("PATH"))
// Locally installed python packages
os.Setenv("PATH", home+"/.local/bin:"+os.Getenv("PATH"))
// Home for virtualenvs
os.Setenv("WORKON_HOME", home+"/.local/share/virtualenvs")
// GO Stuff
os.Setenv("GOPATH", home+"/go")
os.Setenv("PATH", home+"/go/bin:"+os.Getenv("PATH"))
os.Setenv("PATH", "/usr/local/go/bin:"+os.Getenv("PATH"))
os.Setenv("PATH", "/usr/local/gopkg/bin:"+os.Getenv("PATH"))
os.Setenv("GOPRIVATE", "git.snorba.art,git.tar.cx")
os.Setenv("EMACS_SOCKET_NAME", home+"/.cache/emacs/server")
}
func DumpBytesToFile(data []byte, filePath string) error {
fmt.Fprintln(os.Stderr, "Dumping to "+filePath)
err := EnsureDirectory(path.Dir(filePath))
if err != nil {
return err
}
file, err := os.Create(filePath)
if err != nil {
return err
}
writer := bufio.NewWriter(file)
_, err = writer.Write(data)
if err != nil {
return err
}
return writer.Flush()
}
func DumpStringToFile(data string, path string) error {
return DumpBytesToFile([]byte(data), path)
}
func ToggleNightLight() error {
getState := exec.Command(
"gsettings",
"get",
"org.gnome.settings-daemon.plugins.color",
"night-light-enabled",
)
state, err := getState.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, state)
}
stateStr := strings.TrimSpace(string(state))
desiredState := "true"
if stateStr == desiredState {
desiredState = "false"
}
out, err := exec.Command(
"gsettings",
"set",
"org.gnome.settings-daemon.plugins.color",
"night-light-enabled",
desiredState,
).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, out)
}
return nil
}
func FileExists(file string) bool {
_, err := os.Stat(file)
if err != nil {
return false
}
return true
}
func EnsureDirectory(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
err = os.MkdirAll(dir, 0755)
if err != nil {
return fmt.Errorf(
"Failed to create dir %v: %w",
dir,
err,
)
}
}
return nil
}
func DumpInitFiles() error {
home := os.Getenv("HOME")
elispThemeDir := home + "/.workspace/elisp-themes"
err := EnsureDirectory(elispThemeDir)
files := map[string]string{
home + "/.emacs": initLisp,
home + "/.custom.el": customLisp,
home + "/.bash_aliases": bashAliases,
elispThemeDir + "/epaper-theme.el": elispEpaperTheme,
home + "/.gitconfig": gitConfig,
home + "/.gitignore": gitIgnore,
home + "/.config/pantalaimon/pantalaimon.conf": pantalaimonConfig,
}
for file, contents := range files {
err := DumpStringToFile(contents, file)
if err != nil {
return err
}
}
tmp, err := template.New("emacs.desktop").Parse(emacsDesktop)
if err != nil {
return err
}
executable, err := os.Executable()
if err != nil {
return err
}
var out bytes.Buffer
tmp.Execute(&out, map[string]string{
"workspace_bin": executable,
})
err = DumpBytesToFile(
out.Bytes(), home+"/.local/share/applications/emacs.desktop",
)
if err != nil {
return err
}
return DumpBytesToFile(
emacsIcon,
home+"/.local/share/icons/hicolor/128x128/apps/emacs.png",
)
}
func main() {
var cmd string
if len(os.Args) > 1 {
cmd = os.Args[1]
}
var detach bool
run := flag.NewFlagSet("run", flag.ExitOnError)
run.BoolVar(
&detach,
"detach",
false,
"Whether or not to detach from the container after running the command",
)
var privileged bool
run.BoolVar(
&privileged,
"privileged",
false,
"Whether or not to detach from the container after running the command",
)
var mounts arrayFlag
run.Var(&mounts, "mount", "Directory to mount inside the workspace container")
switch cmd {
case "entrypoint":
err := EntryPoint(os.Args[2:])
if err != nil {
fmt.Println("error executing entrypoint command:", err)
os.Exit(1)
}
case "run":
err := run.Parse(os.Args[2:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = Run(privileged, detach, mounts, run.Args())
if err != nil {
fmt.Println("error running command in container", err)
os.Exit(1)
}
case "dump-init-files":
err := DumpInitFiles()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "toggle-nightlight":
err := ToggleNightLight()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Println(usage)
fmt.Println("run COMMAND: Start workspace container and run COMMAND within it")
run.Usage()
}
}