From fc4b25d9fde215745dc416a9437950915be998e8 Mon Sep 17 00:00:00 2001 From: David Anderson Date: Fri, 24 Feb 2023 13:22:21 -0800 Subject: [PATCH] release: open-source release build logic for unix packages Updates tailscale/corp#9221 Signed-off-by: David Anderson --- .gitignore | 1 + cmd/dist/dist.go | 134 +++++++++++ release/deb/debian.postinst.sh | 13 ++ release/deb/debian.postrm.sh | 17 ++ release/deb/debian.prerm.sh | 7 + release/dist/dist.go | 268 ++++++++++++++++++++++ release/dist/memoize.go | 83 +++++++ release/dist/unixpkgs/pkgs.go | 375 +++++++++++++++++++++++++++++++ release/dist/unixpkgs/targets.go | 116 ++++++++++ release/rpm/rpm.postinst.sh | 41 ++++ release/rpm/rpm.postrm.sh | 8 + release/rpm/rpm.prerm.sh | 8 + 12 files changed, 1071 insertions(+) create mode 100644 cmd/dist/dist.go create mode 100755 release/deb/debian.postinst.sh create mode 100755 release/deb/debian.postrm.sh create mode 100755 release/deb/debian.prerm.sh create mode 100644 release/dist/dist.go create mode 100644 release/dist/memoize.go create mode 100644 release/dist/unixpkgs/pkgs.go create mode 100644 release/dist/unixpkgs/targets.go create mode 100755 release/rpm/rpm.postinst.sh create mode 100755 release/rpm/rpm.postrm.sh create mode 100755 release/rpm/rpm.prerm.sh diff --git a/.gitignore b/.gitignore index c824cdf10..a613c538d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ cmd/tailscaled/tailscaled .direnv/ /gocross +/dist diff --git a/cmd/dist/dist.go b/cmd/dist/dist.go new file mode 100644 index 000000000..57ce0c983 --- /dev/null +++ b/cmd/dist/dist.go @@ -0,0 +1,134 @@ +// The dist command builds Tailscale release packages for distribution. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/release/dist" + "tailscale.com/release/dist/unixpkgs" +) + +func main() { + var targets []dist.Target + targets = append(targets, unixpkgs.Targets()...) + sort.Slice(targets, func(i, j int) bool { + return targets[i].String() < targets[j].String() + }) + + rootCmd := &ffcli.Command{ + Name: "dist", + ShortUsage: "dist [flags] [command flags]", + ShortHelp: "Build tailscale release packages for distribution", + LongHelp: `For help on subcommands, add --help after: "dist list --help".`, + Subcommands: []*ffcli.Command{ + { + Name: "list", + Exec: func(ctx context.Context, args []string) error { + return runList(ctx, args, targets) + }, + ShortUsage: "dist list [target filters]", + ShortHelp: "List all available release targets.", + LongHelp: strings.TrimSpace(` + If filters are provided, only targets matching at least one filter are listed. + Filters can use glob patterns (* and ?). + `), + }, + { + Name: "build", + Exec: func(ctx context.Context, args []string) error { + return runBuild(ctx, args, targets) + }, + ShortUsage: "dist build [target filters]", + ShortHelp: "Build release files", + FlagSet: (func() *flag.FlagSet { + fs := flag.NewFlagSet("build", flag.ExitOnError) + fs.StringVar(&buildArgs.manifest, "manifest", "", "manifest file to write") + return fs + })(), + LongHelp: strings.TrimSpace(` + If filters are provided, only targets matching at least one filter are built. + Filters can use glob patterns (* and ?). + `), + }, + }, + Exec: func(context.Context, []string) error { return flag.ErrHelp }, + } + + if err := rootCmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) { + log.Fatal(err) + } +} + +func runList(ctx context.Context, filters []string, targets []dist.Target) error { + tgts, err := dist.FilterTargets(targets, filters) + if err != nil { + return err + } + for _, tgt := range tgts { + fmt.Println(tgt) + } + return nil +} + +var buildArgs struct { + manifest string +} + +func runBuild(ctx context.Context, filters []string, targets []dist.Target) error { + tgts, err := dist.FilterTargets(targets, filters) + if err != nil { + return err + } + if len(tgts) == 0 { + return errors.New("no targets matched (did you mean 'dist build all'?)") + } + + st := time.Now() + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + b, err := dist.NewBuild(wd, filepath.Join(wd, "dist")) + if err != nil { + return fmt.Errorf("creating build context: %w", err) + } + defer b.Close() + + out, err := b.Build(tgts) + if err != nil { + return fmt.Errorf("building targets: %w", err) + } + + if buildArgs.manifest != "" { + // Make the built paths relative to the manifest file. + manifest, err := filepath.Abs(buildArgs.manifest) + if err != nil { + return fmt.Errorf("getting absolute path of manifest: %w", err) + } + fmt.Println(manifest) + fmt.Println(filepath.Join(b.Out, out[0])) + for i := range out { + rel, err := filepath.Rel(filepath.Dir(manifest), filepath.Join(b.Out, out[i])) + if err != nil { + return fmt.Errorf("making path relative: %w", err) + } + out[i] = rel + } + if err := os.WriteFile(manifest, []byte(strings.Join(out, "\n")), 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + } + + fmt.Println("Done! Took", time.Since(st)) + return nil +} diff --git a/release/deb/debian.postinst.sh b/release/deb/debian.postinst.sh new file mode 100755 index 000000000..5241a0dc8 --- /dev/null +++ b/release/deb/debian.postinst.sh @@ -0,0 +1,13 @@ +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then + deb-systemd-helper unmask 'tailscaled.service' >/dev/null || true + if deb-systemd-helper --quiet was-enabled 'tailscaled.service'; then + deb-systemd-helper enable 'tailscaled.service' >/dev/null || true + else + deb-systemd-helper update-state 'tailscaled.service' >/dev/null || true + fi + + if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null || true + deb-systemd-invoke restart 'tailscaled.service' >/dev/null || true + fi +fi diff --git a/release/deb/debian.postrm.sh b/release/deb/debian.postrm.sh new file mode 100755 index 000000000..f4dd4ed9c --- /dev/null +++ b/release/deb/debian.postrm.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -e +if [ -d /run/systemd/system ] ; then + systemctl --system daemon-reload >/dev/null || true +fi + +if [ -x "/usr/bin/deb-systemd-helper" ]; then + if [ "$1" = "remove" ]; then + deb-systemd-helper mask 'tailscaled.service' >/dev/null || true + fi + + if [ "$1" = "purge" ]; then + deb-systemd-helper purge 'tailscaled.service' >/dev/null || true + deb-systemd-helper unmask 'tailscaled.service' >/dev/null || true + rm -rf /var/lib/tailscale + fi +fi diff --git a/release/deb/debian.prerm.sh b/release/deb/debian.prerm.sh new file mode 100755 index 000000000..9be58ede4 --- /dev/null +++ b/release/deb/debian.prerm.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +if [ "$1" = "remove" ]; then + if [ -d /run/systemd/system ]; then + deb-systemd-invoke stop 'tailscaled.service' >/dev/null || true + fi +fi diff --git a/release/dist/dist.go b/release/dist/dist.go new file mode 100644 index 000000000..b2785d99e --- /dev/null +++ b/release/dist/dist.go @@ -0,0 +1,268 @@ +// Package dist is a release artifact builder library. +package dist + +import ( + "errors" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + + "tailscale.com/util/multierr" + "tailscale.com/version/mkversion" +) + +// A Target is something that can be build in a Build. +type Target interface { + String() string + Build(build *Build) ([]string, error) +} + +// A Build is a build context for Targets. +type Build struct { + // Repo is a path to the root Go module for the build. + Repo string + // Tmp is a temporary directory that gets deleted when the Builder is closed. + Tmp string + // Out is where build artifacts are written. + Out string + // Go is the path to the Go binary to use for building. + Go string + // Version is the version info of the build. + Version mkversion.VersionInfo + + // once is a cache of function invocations that should run once per process + // (for example building a helper docker container) + once once + + extraMu sync.Mutex + extra map[any]any + + goBuilds Memoize[string] + // When running `dist build all` on a cold Go build cache, the fanout of + // gooses and goarches results in a very large number of compile processes, + // which bogs down the build machine. + // + // This throttles the number of concurrent `go build` invocations to the + // number of CPU cores, which empirically keeps the builder responsive + // without impacting overall build time. + goBuildLimit chan struct{} +} + +// NewBuild creates a new Build rooted at repo, and writing artifacts to out. +func NewBuild(repo, out string) (*Build, error) { + if err := os.MkdirAll(out, 0750); err != nil { + return nil, fmt.Errorf("creating out dir: %w", err) + } + tmp, err := os.MkdirTemp("", "dist-*") + if err != nil { + return nil, fmt.Errorf("creating tempdir: %w", err) + } + repo, err = findModRoot(repo) + if err != nil { + return nil, fmt.Errorf("finding module root: %w", err) + } + goTool, err := findGo(repo) + if err != nil { + return nil, fmt.Errorf("finding go binary: %w", err) + } + b := &Build{ + Repo: repo, + Tmp: tmp, + Out: out, + Go: goTool, + Version: mkversion.Info(), + extra: map[any]any{}, + goBuildLimit: make(chan struct{}, runtime.NumCPU()), + } + + return b, nil +} + +// Close ends the build and cleans up temporary files. +func (b *Build) Close() error { + return os.RemoveAll(b.Tmp) +} + +// Build builds all targets concurrently. +func (b *Build) Build(targets []Target) (files []string, err error) { + if len(targets) == 0 { + return nil, errors.New("no targets specified") + } + log.Printf("Building %d targets: %v", len(targets), targets) + var ( + wg sync.WaitGroup + errs = make([]error, len(targets)) + buildFiles = make([][]string, len(targets)) + ) + for i, t := range targets { + wg.Add(1) + go func(i int, t Target) { + var err error + defer func() { + errs[i] = err + wg.Done() + }() + fs, err := t.Build(b) + buildFiles[i] = fs + }(i, t) + } + wg.Wait() + + for _, fs := range buildFiles { + files = append(files, fs...) + } + sort.Strings(files) + + return files, multierr.New(errs...) +} + +// Once runs fn if Once hasn't been called with name before. +func (b *Build) Once(name string, fn func() error) error { + return b.once.Do(name, fn) +} + +// Extra returns a value from the build's extra state, creating it if necessary. +func (b *Build) Extra(key any, constructor func() any) any { + b.extraMu.Lock() + defer b.extraMu.Unlock() + ret, ok := b.extra[key] + if !ok { + ret = constructor() + b.extra[key] = ret + } + return ret +} + +// GoPkg returns the path on disk of pkg. +// The module of pkg must be imported in b.Repo's go.mod. +func (b *Build) GoPkg(pkg string) (string, error) { + bs, err := exec.Command(b.Go, "list", "-f", "{{.Dir}}", pkg).Output() + if err != nil { + return "", fmt.Errorf("finding package %q: %w", pkg, err) + } + return strings.TrimSpace(string(bs)), nil +} + +// TmpDir creates and returns a new empty temporary directory. +// The caller does not need to clean up the directory after use, it will get +// deleted by b.Close(). +func (b *Build) TmpDir() string { + // Because we're creating all temp dirs in our parent temp dir, the only + // failures that can happen at this point are sequence breaks (e.g. if b.Tmp + // is deleted while stuff is still running). So, panic on error to slightly + // simplify callsites. + ret, err := os.MkdirTemp(b.Tmp, "") + if err != nil { + panic(fmt.Sprintf("creating temp dir: %v", err)) + } + return ret +} + +// BuildGoBinary builds the Go binary at path and returns the path to the +// binary. Builds are cached by path and env, so each build only happens once +// per process execution. +func (b *Build) BuildGoBinary(path string, env map[string]string) (string, error) { + err := b.Once("init-go", func() error { + log.Printf("Initializing Go toolchain") + // If the build is using a tool/go, it may need to download a toolchain + // and do other initialization. Running `go version` once takes care of + // all of that and avoids that initialization happening concurrently + // later on in builds. + _, err := exec.Command(b.Go, "version").Output() + return err + }) + if err != nil { + return "", err + } + + buildKey := []any{"go-build", path, env} + return b.goBuilds.Do(buildKey, func() (string, error) { + b.goBuildLimit <- struct{}{} + defer func() { <-b.goBuildLimit }() + + var envStrs []string + for k, v := range env { + envStrs = append(envStrs, k+"="+v) + } + sort.Strings(envStrs) + log.Printf("Building %s (with env %s)", path, strings.Join(envStrs, " ")) + buildDir := b.TmpDir() + cmd := exec.Command(b.Go, "build", "-o", buildDir, path) + cmd.Dir = b.Repo + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + cmd.Env = append(cmd.Env, "TS_USE_GOCROSS=1") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", err + } + out := filepath.Join(buildDir, filepath.Base(path)) + if env["GOOS"] == "windows" || env["GOOS"] == "windowsgui" { + out += ".exe" + } + return out, nil + }) +} + +func findModRoot(path string) (string, error) { + for { + modpath := filepath.Join(path, "go.mod") + if _, err := os.Stat(modpath); err == nil { + return path, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + path = filepath.Dir(path) + if path == "/" { + return "", fmt.Errorf("no go.mod found in %q or any parent directory", path) + } + } +} + +func findGo(path string) (string, error) { + toolGo := filepath.Join(path, "tool/go") + if _, err := os.Stat(toolGo); err == nil { + return toolGo, nil + } + toolGo, err := exec.LookPath("go") + if err != nil { + return "", err + } + return toolGo, nil +} + +// FilterTargets returns the subset of targets that match any of the filters. +// If filters is empty, returns all targets. +func FilterTargets(targets []Target, filters []string) ([]Target, error) { + var filts []*regexp.Regexp + for _, f := range filters { + if f == "all" { + return targets, nil + } + filt, err := regexp.Compile(f) + if err != nil { + return nil, fmt.Errorf("invalid filter %q: %w", f, err) + } + filts = append(filts, filt) + } + var ret []Target + for _, t := range targets { + for _, filt := range filts { + if filt.MatchString(t.String()) { + ret = append(ret, t) + break + } + } + } + return ret, nil +} diff --git a/release/dist/memoize.go b/release/dist/memoize.go new file mode 100644 index 000000000..8fa644838 --- /dev/null +++ b/release/dist/memoize.go @@ -0,0 +1,83 @@ +package dist + +import ( + "sync" + + "tailscale.com/util/deephash" +) + +// MemoizedFn is a function that memoize.Do can call. +type MemoizedFn[T any] func() (T, error) + +// Memoize runs MemoizedFns and remembers their results. +type Memoize[O any] struct { + mu sync.Mutex + cond *sync.Cond + outs map[deephash.Sum]O + errs map[deephash.Sum]error + inflight map[deephash.Sum]bool +} + +// Do runs fn and returns its result. +// fn is only run once per unique key. Subsequent Do calls with the same key +// return the memoized result of the first call, even if fn is a different +// function. +func (m *Memoize[O]) Do(key any, fn MemoizedFn[O]) (ret O, err error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.cond == nil { + m.cond = sync.NewCond(&m.mu) + m.outs = map[deephash.Sum]O{} + m.errs = map[deephash.Sum]error{} + m.inflight = map[deephash.Sum]bool{} + } + + k := deephash.Hash(&key) + + for m.inflight[k] { + m.cond.Wait() + } + if err := m.errs[k]; err != nil { + var ret O + return ret, err + } + if ret, ok := m.outs[k]; ok { + return ret, nil + } + + m.inflight[k] = true + m.mu.Unlock() + defer func() { + m.mu.Lock() + delete(m.inflight, k) + if err != nil { + m.errs[k] = err + } else { + m.outs[k] = ret + } + m.cond.Broadcast() + }() + + ret, err = fn() + if err != nil { + var ret O + return ret, err + } + return ret, nil +} + +// once is like memoize, but for functions that don't return non-error values. +type once struct { + m Memoize[any] +} + +// Do runs fn. +// fn is only run once per unique key. Subsequent Do calls with the same key +// return the memoized result of the first call, even if fn is a different +// function. +func (o *once) Do(key any, fn func() error) error { + _, err := o.m.Do(key, func() (any, error) { + return nil, fn() + }) + return err +} diff --git a/release/dist/unixpkgs/pkgs.go b/release/dist/unixpkgs/pkgs.go new file mode 100644 index 000000000..209325451 --- /dev/null +++ b/release/dist/unixpkgs/pkgs.go @@ -0,0 +1,375 @@ +// Package unixpkgs contains dist Targets for building unix Tailscale packages. +package unixpkgs + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/goreleaser/nfpm" + "tailscale.com/release/dist" +) + +type tgzTarget struct { + filenameArch string // arch to use in filename instead of deriving from goenv["GOARCH"] + goenv map[string]string +} + +func (t *tgzTarget) arch() string { + if t.filenameArch != "" { + return t.filenameArch + } + return t.goenv["GOARCH"] +} + +func (t *tgzTarget) os() string { + return t.goenv["GOOS"] +} + +func (t *tgzTarget) String() string { + return fmt.Sprintf("%s/%s/tgz", t.os(), t.arch()) +} + +func (t *tgzTarget) Build(b *dist.Build) ([]string, error) { + var filename string + if t.goenv["GOOS"] == "linux" { + // Linux used to be the only tgz architecture, so we didn't put the OS + // name in the filename. + filename = fmt.Sprintf("tailscale_%s_%s.tgz", b.Version.Short, t.arch()) + } else { + filename = fmt.Sprintf("tailscale_%s_%s_%s.tgz", b.Version.Short, t.os(), t.arch()) + } + ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv) + if err != nil { + return nil, err + } + tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv) + if err != nil { + return nil, err + } + + log.Printf("Building %s", filename) + + out := filepath.Join(b.Out, filename) + f, err := os.Create(out) + if err != nil { + return nil, err + } + defer f.Close() + gw := gzip.NewWriter(f) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + buildTime := time.Now() + addFile := func(src, dst string, mode int64) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return err + } + hdr := &tar.Header{ + Name: dst, + Size: fi.Size(), + Mode: mode, + ModTime: buildTime, + Uid: 0, + Gid: 0, + Uname: "root", + Gname: "root", + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if _, err = io.Copy(tw, f); err != nil { + return err + } + return nil + } + addDir := func(name string) error { + hdr := &tar.Header{ + Name: name + "/", + Mode: 0755, + ModTime: buildTime, + Uid: 0, + Gid: 0, + Uname: "root", + Gname: "root", + } + return tw.WriteHeader(hdr) + } + dir := strings.TrimSuffix(filename, ".tgz") + if err := addDir(dir); err != nil { + return nil, err + } + if err := addFile(tsd, filepath.Join(dir, "tailscaled"), 0755); err != nil { + return nil, err + } + if err := addFile(ts, filepath.Join(dir, "tailscale"), 0755); err != nil { + return nil, err + } + if t.os() == "linux" { + dir = filepath.Join(dir, "systemd") + if err := addDir(dir); err != nil { + return nil, err + } + tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") + if err != nil { + return nil, err + } + if err := addFile(filepath.Join(tailscaledDir, "tailscaled.service"), filepath.Join(dir, "tailscaled.service"), 0644); err != nil { + return nil, err + } + if err := addFile(filepath.Join(tailscaledDir, "tailscaled.defaults"), filepath.Join(dir, "tailscaled.defaults"), 0644); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gw.Close(); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + return []string{filename}, nil +} + +type debTarget struct { + goenv map[string]string +} + +func (t *debTarget) os() string { + return t.goenv["GOOS"] +} + +func (t *debTarget) arch() string { + return t.goenv["GOARCH"] +} + +func (t *debTarget) String() string { + return fmt.Sprintf("linux/%s/deb", t.goenv["GOARCH"]) +} + +func (t *debTarget) Build(b *dist.Build) ([]string, error) { + if t.os() != "linux" { + return nil, errors.New("deb only supported on linux") + } + + ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv) + if err != nil { + return nil, err + } + tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv) + if err != nil { + return nil, err + } + + tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") + if err != nil { + return nil, err + } + repoDir, err := b.GoPkg("tailscale.com") + if err != nil { + return nil, err + } + + arch := debArch(t.arch()) + info := nfpm.WithDefaults(&nfpm.Info{ + Name: "tailscale", + Arch: arch, + Platform: "linux", + Version: b.Version.Short, + Maintainer: "Tailscale Inc ", + Description: "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", + Homepage: "https://www.tailscale.com", + License: "MIT", + Section: "net", + Priority: "extra", + Overridables: nfpm.Overridables{ + Files: map[string]string{ + ts: "/usr/bin/tailscale", + tsd: "/usr/sbin/tailscaled", + filepath.Join(tailscaledDir, "tailscaled.service"): "/lib/systemd/system/tailscaled.service", + }, + ConfigFiles: map[string]string{ + filepath.Join(tailscaledDir, "tailscaled.defaults"): "/etc/default/tailscaled", + }, + Scripts: nfpm.Scripts{ + PostInstall: filepath.Join(repoDir, "release/deb/debian.postinst.sh"), + PreRemove: filepath.Join(repoDir, "release/deb/debian.prerm.sh"), + PostRemove: filepath.Join(repoDir, "release/deb/debian.postrm.sh"), + }, + Depends: []string{"iptables", "iproute2"}, + Recommends: []string{"tailscale-archive-keyring (>= 1.35.181)"}, + Replaces: []string{"tailscale-relay"}, + Conflicts: []string{"tailscale-relay"}, + }, + }) + pkg, err := nfpm.Get("deb") + if err != nil { + return nil, err + } + + filename := fmt.Sprintf("tailscale_%s_%s.deb", b.Version.Short, arch) + log.Printf("Building %s", filename) + f, err := os.Create(filepath.Join(b.Out, filename)) + if err != nil { + return nil, err + } + defer f.Close() + if err := pkg.Package(info, f); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + return []string{filename}, nil +} + +type rpmTarget struct { + goenv map[string]string +} + +func (t *rpmTarget) os() string { + return t.goenv["GOOS"] +} + +func (t *rpmTarget) arch() string { + return t.goenv["GOARCH"] +} + +func (t *rpmTarget) String() string { + return fmt.Sprintf("linux/%s/rpm", t.arch()) +} + +func (t *rpmTarget) Build(b *dist.Build) ([]string, error) { + if t.os() != "linux" { + return nil, errors.New("rpm only supported on linux") + } + + ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", t.goenv) + if err != nil { + return nil, err + } + tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", t.goenv) + if err != nil { + return nil, err + } + + tailscaledDir, err := b.GoPkg("tailscale.com/cmd/tailscaled") + if err != nil { + return nil, err + } + repoDir, err := b.GoPkg("tailscale.com") + if err != nil { + return nil, err + } + + arch := rpmArch(t.arch()) + info := nfpm.WithDefaults(&nfpm.Info{ + Name: "tailscale", + Arch: arch, + Platform: "linux", + Version: b.Version.Short, + Maintainer: "Tailscale Inc ", + Description: "The easiest, most secure, cross platform way to use WireGuard + oauth2 + 2FA/SSO", + Homepage: "https://www.tailscale.com", + License: "MIT", + Overridables: nfpm.Overridables{ + Files: map[string]string{ + ts: "/usr/bin/tailscale", + tsd: "/usr/sbin/tailscaled", + filepath.Join(tailscaledDir, "tailscaled.service"): "/lib/systemd/system/tailscaled.service", + }, + ConfigFiles: map[string]string{ + filepath.Join(tailscaledDir, "tailscaled.defaults"): "/etc/default/tailscaled", + }, + // SELinux policy on e.g. CentOS 8 forbids writing to /var/cache. + // Creating an empty directory at install time resolves this issue. + EmptyFolders: []string{"/var/cache/tailscale"}, + Scripts: nfpm.Scripts{ + PostInstall: filepath.Join(repoDir, "release/rpm/rpm.postinst.sh"), + PreRemove: filepath.Join(repoDir, "release/rpm/rpm.prerm.sh"), + PostRemove: filepath.Join(repoDir, "release/rpm/rpm.postrm.sh"), + }, + Depends: []string{"iptables", "iproute"}, + Replaces: []string{"tailscale-relay"}, + Conflicts: []string{"tailscale-relay"}, + RPM: nfpm.RPM{ + Group: "Network", + }, + }, + }) + pkg, err := nfpm.Get("rpm") + if err != nil { + return nil, err + } + + filename := fmt.Sprintf("tailscale_%s_%s.rpm", b.Version.Short, arch) + log.Printf("Building %s", filename) + + f, err := os.Create(filepath.Join(b.Out, filename)) + if err != nil { + return nil, err + } + defer f.Close() + if err := pkg.Package(info, f); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + return []string{filename}, nil +} + +// debArch returns the debian arch name for the given Go arch name. +// nfpm also does this translation internally, but we need to do it outside nfpm +// because we also need the filename to be correct. +func debArch(arch string) string { + switch arch { + case "386": + return "i386" + case "arm": + // TODO: this is supposed to be "armel" for GOARM=5, and "armhf" for + // GOARM=6 and 7. But we have some tech debt to pay off here before we + // can ship more than 1 ARM deb, so for now match redo's behavior of + // shipping armv5 binaries in an armv7 trenchcoat. + return "armhf" + default: + return arch + } +} + +// rpmArch returns the RPM arch name for the given Go arch name. +// nfpm also does this translation internally, but we need to do it outside nfpm +// because we also need the filename to be correct. +func rpmArch(arch string) string { + switch arch { + case "amd64": + return "x86_64" + case "386": + return "i386" + case "arm": + return "armv7hl" + case "arm64": + return "aarch64" + default: + return arch + } +} diff --git a/release/dist/unixpkgs/targets.go b/release/dist/unixpkgs/targets.go new file mode 100644 index 000000000..40b561b24 --- /dev/null +++ b/release/dist/unixpkgs/targets.go @@ -0,0 +1,116 @@ +package unixpkgs + +import ( + "fmt" + "sort" + "strings" + + "tailscale.com/release/dist" + + _ "github.com/goreleaser/nfpm/deb" + _ "github.com/goreleaser/nfpm/rpm" +) + +func Targets() []dist.Target { + var ret []dist.Target + for goosgoarch := range tarballs { + goos, goarch := splitGoosGoarch(goosgoarch) + ret = append(ret, &tgzTarget{ + goenv: map[string]string{ + "GOOS": goos, + "GOARCH": goarch, + }, + }) + } + for goosgoarch := range debs { + goos, goarch := splitGoosGoarch(goosgoarch) + ret = append(ret, &debTarget{ + goenv: map[string]string{ + "GOOS": goos, + "GOARCH": goarch, + }, + }) + } + for goosgoarch := range rpms { + goos, goarch := splitGoosGoarch(goosgoarch) + ret = append(ret, &rpmTarget{ + goenv: map[string]string{ + "GOOS": goos, + "GOARCH": goarch, + }, + }) + } + + // Special case: AMD Geode is 386 with softfloat. Tarballs only since it's + // an ancient architecture. + ret = append(ret, &tgzTarget{ + filenameArch: "geode", + goenv: map[string]string{ + "GOOS": "linux", + "GOARCH": "386", + "GO386": "softfloat", + }, + }) + + sort.Slice(ret, func(i, j int) bool { + return ret[i].String() < ret[j].String() + }) + + return ret +} + +var ( + tarballs = map[string]bool{ + "linux/386": true, + "linux/amd64": true, + "linux/arm": true, + "linux/arm64": true, + "linux/mips64": true, + "linux/mips64le": true, + "linux/mips": true, + "linux/mipsle": true, + "linux/riscv64": true, + // TODO: more tarballs we could distribute, but don't currently. Leaving + // out for initial parity with redo. + // "darwin/amd64": true, + // "darwin/arm64": true, + // "freebsd/amd64": true, + // "openbsd/amd64": true, + } + + debs = map[string]bool{ + "linux/386": true, + "linux/amd64": true, + "linux/arm": true, + "linux/arm64": true, + "linux/riscv64": true, + // TODO: maybe mipses, we accidentally started building them at some + // point even though they probably don't work right. + // "linux/mips": true, + // "linux/mipsle": true, + // "linux/mips64": true, + // "linux/mips64le": true, + } + + rpms = map[string]bool{ + "linux/386": true, + "linux/amd64": true, + "linux/arm": true, + "linux/arm64": true, + "linux/riscv64": true, + // TODO: maybe mipses, we accidentally started building them at some + // point even though they probably don't work right. + // "linux/mips": true, + // "linux/mipsle": true, + // "linux/mips64": true, + // "linux/mips64le": true, + } +) + +func splitGoosGoarch(s string) (string, string) { + goos, goarch, ok := strings.Cut(s, "/") + if !ok { + panic(fmt.Sprintf("invalid target %q", s)) + } + return goos, goarch +} diff --git a/release/rpm/rpm.postinst.sh b/release/rpm/rpm.postinst.sh new file mode 100755 index 000000000..3d264c5f6 --- /dev/null +++ b/release/rpm/rpm.postinst.sh @@ -0,0 +1,41 @@ +# $1 == 1 for initial installation. +# $1 == 2 for upgrades. + +if [ $1 -eq 1 ] ; then + # Normally, the tailscale-relay package would request shutdown of + # its service before uninstallation. Unfortunately, the + # tailscale-relay package we distributed doesn't have those + # scriptlets. We definitely want relaynode to be stopped when + # installing tailscaled though, so we blindly try to turn off + # relaynode here. + # + # However, we also want this package installation to look like an + # upgrade from relaynode! Therefore, if relaynode is currently + # enabled, we want to also enable tailscaled. If relaynode is + # currently running, we also want to start tailscaled. + # + # If there doesn't seem to be an active or enabled relaynode on + # the system, we follow the RPM convention for package installs, + # which is to not enable or start the service. + relaynode_enabled=0 + relaynode_running=0 + if systemctl is-enabled tailscale-relay.service >/dev/null 2>&1; then + relaynode_enabled=1 + fi + if systemctl is-active tailscale-relay.service >/dev/null 2>&1; then + relaynode_running=1 + fi + + systemctl --no-reload disable tailscale-relay.service >/dev/null 2>&1 || : + systemctl stop tailscale-relay.service >/dev/null 2>&1 || : + + if [ $relaynode_enabled -eq 1 ]; then + systemctl enable tailscaled.service >/dev/null 2>&1 || : + else + systemctl preset tailscaled.service >/dev/null 2>&1 || : + fi + + if [ $relaynode_running -eq 1 ]; then + systemctl start tailscaled.service >/dev/null 2>&1 || : + fi +fi diff --git a/release/rpm/rpm.postrm.sh b/release/rpm/rpm.postrm.sh new file mode 100755 index 000000000..d74f7e9de --- /dev/null +++ b/release/rpm/rpm.postrm.sh @@ -0,0 +1,8 @@ +# $1 == 0 for uninstallation. +# $1 == 1 for removing old package during upgrade. + +systemctl daemon-reload >/dev/null 2>&1 || : +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart tailscaled.service >/dev/null 2>&1 || : +fi diff --git a/release/rpm/rpm.prerm.sh b/release/rpm/rpm.prerm.sh new file mode 100755 index 000000000..682c01bd5 --- /dev/null +++ b/release/rpm/rpm.prerm.sh @@ -0,0 +1,8 @@ +# $1 == 0 for uninstallation. +# $1 == 1 for removing old package during upgrade. + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable tailscaled.service > /dev/null 2>&1 || : + systemctl stop tailscaled.service > /dev/null 2>&1 || : +fi