tailscale/release/dist/qnap/pkgs.go

279 lines
8.4 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package qnap contains dist Targets for building QNAP Tailscale packages.
//
// QNAP dev docs over at https://www.qnap.com/en/how-to/tutorial/article/qpkg-development-guidelines.
package qnap
import (
"embed"
"fmt"
"io/fs"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"tailscale.com/release/dist"
)
type target struct {
goenv map[string]string
arch string
signer *signer
}
type signer struct {
privateKeyPath string
certificatePath string
}
func (t *target) String() string {
return fmt.Sprintf("qnap/%s", t.arch)
}
func (t *target) Build(b *dist.Build) ([]string, error) {
// Stop early if we don't have docker running.
if _, err := exec.LookPath("docker"); err != nil {
return nil, fmt.Errorf("docker not found, cannot build: %w", err)
}
qnapBuilds := getQnapBuilds(b, t.signer)
inner, err := qnapBuilds.buildInnerPackage(b, t.goenv)
if err != nil {
return nil, err
}
return t.buildQPKG(b, qnapBuilds, inner)
}
const (
qnapTag = "1" // currently static, we don't seem to bump this
)
func (t *target) buildQPKG(b *dist.Build, qnapBuilds *qnapBuilds, inner *innerPkg) ([]string, error) {
if _, err := exec.LookPath("docker"); err != nil {
return nil, fmt.Errorf("docker not found, cannot build: %w", err)
}
if err := qnapBuilds.makeDockerImage(b); err != nil {
return nil, fmt.Errorf("makeDockerImage: %w", err)
}
filename := fmt.Sprintf("Tailscale_%s-%s_%s.qpkg", b.Version.Short, qnapTag, t.arch)
filePath := filepath.Join(b.Out, filename)
cmd := b.Command(b.Repo, "docker", "run", "--rm",
"-e", fmt.Sprintf("ARCH=%s", t.arch),
"-e", fmt.Sprintf("TSTAG=%s", b.Version.Short),
"-e", fmt.Sprintf("QNAPTAG=%s", qnapTag),
"-v", fmt.Sprintf("%s:/tailscale", inner.tailscalePath),
"-v", fmt.Sprintf("%s:/tailscaled", inner.tailscaledPath),
// Tailscale folder has QNAP package setup files needed for building.
"-v", fmt.Sprintf("%s:/Tailscale", filepath.Join(qnapBuilds.tmpDir, "files/Tailscale")),
"-v", fmt.Sprintf("%s:/build-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/build-qpkg.sh")),
"-v", fmt.Sprintf("%s:/out", b.Out),
"build.tailscale.io/qdk:latest",
"/build-qpkg.sh",
)
// dist.Build runs target builds in parallel goroutines by default.
// For QNAP, this is an issue because the underlaying qbuild builder will
// create tmp directories in the shared docker image that end up conflicting
// with one another.
// So we use a mutex to only allow one "docker run" at a time.
qnapBuilds.dockerImageMu.Lock()
defer qnapBuilds.dockerImageMu.Unlock()
log.Printf("Building %s", filePath)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("docker run %v: %s", err, out)
}
return []string{filePath, filePath + ".md5"}, nil
}
type qnapBuildsMemoizeKey struct{}
type innerPkg struct {
tailscalePath string
tailscaledPath string
}
// qnapBuilds holds extra build context shared by all qnap builds.
type qnapBuilds struct {
// innerPkgs contains per-goenv compiled binary paths.
// It is used to avoid repeated compilations for the same architecture.
innerPkgs dist.Memoize[*innerPkg]
dockerImageMu sync.Mutex
// tmpDir is a temp directory used for building qpkgs.
// It gets cleaned up when the dist.Build is closed.
tmpDir string
}
// getQnapBuilds returns the qnapBuilds for b, creating one if needed.
func getQnapBuilds(b *dist.Build, signer *signer) *qnapBuilds {
return b.Extra(qnapBuildsMemoizeKey{}, func() any {
builds, err := newQNAPBuilds(b, signer)
if err != nil {
panic(fmt.Errorf("setUpTmpDir: %v", err))
}
return builds
}).(*qnapBuilds)
}
//go:embed all:files
var buildFiles embed.FS
// newQNAPBuilds creates a new qnapBuilds instance to hold context shared by
// all qnap targets, and sets up its local temp directory used for building.
//
// The qnapBuilds.tmpDir is filled with the contents of the buildFiles embedded
// FS for building.
//
// We do this to allow for this tailscale.com/release/dist/qnap package to be
// used from both the corp and OSS repos. When built from OSS source directly,
// this is a superfluous extra step, but when imported as a go module to another
// repo (such as corp), we must do this to allow for the module's build files
// to be reachable and editable from docker.
//
// This runs only once per dist.Build instance, is shared by all qnap targets,
// and gets cleaned up upon close of the dist.Build.
//
// When a signer is provided, newQNAPBuilds also sets up the qpkg signature
// files in qbuild's expected location within m.tmpDir.
func newQNAPBuilds(b *dist.Build, signer *signer) (*qnapBuilds, error) {
m := new(qnapBuilds)
log.Print("Setting up qnap tmp build directory")
m.tmpDir = filepath.Join(b.Repo, "tmp-qnap-build")
b.AddOnCloseFunc(func() error {
return os.RemoveAll(m.tmpDir)
})
if err := fs.WalkDir(buildFiles, "files", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
outPath := filepath.Join(m.tmpDir, path)
if d.IsDir() {
return os.MkdirAll(outPath, 0700)
}
file, err := fs.ReadFile(buildFiles, path)
if err != nil {
return err
}
perm := fs.FileMode(0600)
if filepath.Ext(path) == ".sh" {
perm = 0700
}
return os.WriteFile(outPath, file, perm)
}); err != nil {
return nil, err
}
if signer != nil {
log.Print("Setting up qnap signing files")
key, err := os.ReadFile(signer.privateKeyPath)
if err != nil {
return nil, err
}
cert, err := os.ReadFile(signer.certificatePath)
if err != nil {
return nil, err
}
// QNAP's qbuild command expects key and cert files to be in the root
// of the project directory (in our case release/dist/qnap/Tailscale).
// So here, we copy the key and cert over to the project folder for the
// duration of qnap package building and then delete them on close.
keyPath := filepath.Join(m.tmpDir, "files/Tailscale/private_key")
if err := os.WriteFile(keyPath, key, 0400); err != nil {
return nil, err
}
certPath := filepath.Join(m.tmpDir, "files/Tailscale/certificate")
if err := os.WriteFile(certPath, cert, 0400); err != nil {
return nil, err
}
}
return m, nil
}
// buildInnerPackage builds the go binaries used for qnap packages.
// These binaries get embedded with Tailscale package metadata to form qnap
// releases.
func (m *qnapBuilds) buildInnerPackage(b *dist.Build, goenv map[string]string) (*innerPkg, error) {
return m.innerPkgs.Do(goenv, func() (*innerPkg, error) {
if err := b.BuildWebClientAssets(); err != nil {
return nil, err
}
ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", goenv)
if err != nil {
return nil, err
}
tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", goenv)
if err != nil {
return nil, err
}
// The go binaries above get built and put into a /tmp directory created
// by b.TmpDir(). But, we build QNAP with docker, which doesn't always
// allow for mounting tmp directories (seemingly dependent on docker
// host).
// https://stackoverflow.com/questions/65267251/docker-bind-mount-directory-in-tmp-not-working
//
// So here, we move the binaries into a directory within the b.Repo
// path and clean it up when the builder closes.
tmpDir := filepath.Join(m.tmpDir, fmt.Sprintf("/binaries-%s-%s-%s", b.Version.Short, goenv["GOOS"], goenv["GOARCH"]))
if err = os.MkdirAll(tmpDir, 0755); err != nil {
return nil, err
}
b.AddOnCloseFunc(func() error {
return os.RemoveAll(tmpDir)
})
tsBytes, err := os.ReadFile(ts)
if err != nil {
return nil, err
}
tsdBytes, err := os.ReadFile(tsd)
if err != nil {
return nil, err
}
tsPath := filepath.Join(tmpDir, "tailscale")
if err := os.WriteFile(tsPath, tsBytes, 0755); err != nil {
return nil, err
}
tsdPath := filepath.Join(tmpDir, "tailscaled")
if err := os.WriteFile(tsdPath, tsdBytes, 0755); err != nil {
return nil, err
}
return &innerPkg{tailscalePath: tsPath, tailscaledPath: tsdPath}, nil
})
}
func (m *qnapBuilds) makeDockerImage(b *dist.Build) error {
return b.Once("make-qnap-docker-image", func() error {
log.Printf("Building qnapbuilder docker image")
cmd := b.Command(b.Repo, "docker", "build",
"-f", filepath.Join(m.tmpDir, "files/scripts/Dockerfile.qpkg"),
"-t", "build.tailscale.io/qdk:latest",
filepath.Join(m.tmpDir, "files/scripts"),
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build %v: %s", err, out)
}
return nil
})
}