Files
kandev/apps/backend/cmd/preview/build.go
T
2026-06-20 19:40:15 +01:00

303 lines
8.8 KiB
Go

package main
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
)
// appsDir is the apps/ workspace root relative to the working directory (apps/backend/).
const appsDir = ".."
const repoRootDir = "../.."
// goDockerImage is used to cross-compile CGO binaries on non-linux/amd64 hosts.
const goDockerImage = "golang:1.26-bookworm"
// buildLinuxBinaries compiles kandev, agentctl, and mock-agent for linux/amd64.
// Run this from apps/backend/. agentctl and mock-agent use CGO_ENABLED=0 and
// always build natively. kandev requires CGO (SQLite) and is always built inside
// a Docker container to target a known glibc version (Debian Bookworm = 2.36).
func buildLinuxBinaries(ctx context.Context, outDir string) error {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
// agentctl and mock-agent don't need CGO — build natively with cross-env.
for _, b := range []struct{ name, pkg string }{
{"agentctl", "./cmd/agentctl"},
{"mock-agent", "./cmd/mock-agent"},
} {
out := filepath.Join(outDir, b.name)
fmt.Fprintf(os.Stderr, " go build %s -> %s\n", b.pkg, out)
cmd := exec.CommandContext(ctx, "go", "build", "-ldflags", "-s -w", "-o", out, b.pkg)
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("build %s: %w", b.name, err)
}
}
// kandev requires CGO for SQLite. Always build inside Docker so the resulting
// binary targets a known glibc version (Debian Bookworm = 2.36) regardless of
// the host OS. Sprites VMs run a compatible glibc; building natively on the
// CI runner (Ubuntu 24.04, glibc 2.39) would produce a binary that requires
// symbols unavailable in the Sprites environment.
kandevOut := filepath.Join(outDir, "kandev")
fmt.Fprintf(os.Stderr, " go build ./cmd/kandev (docker cross-compile) -> %s\n", kandevOut)
return buildKandevDocker(ctx, kandevOut)
}
// buildKandevDocker builds kandev inside a linux/amd64 Docker container.
// apps/backend is mounted at /work; the output is written to /work/bin/kandev
// then copied to the host out path.
func buildKandevDocker(ctx context.Context, out string) error {
wd, err := os.Getwd()
if err != nil {
return err
}
// Ensure the host-side bin/ directory exists before Docker tries to write
// into it. `go build -o` does not create missing parent directories.
if err := os.MkdirAll(filepath.Join(wd, "bin"), 0o755); err != nil {
return fmt.Errorf("mkdir bin: %w", err)
}
// Output inside the container (relative to /work mount).
containerOut := "/work/bin/kandev-preview-build"
goCache := filepath.Join(os.Getenv("HOME"), ".cache", "go-build-linux")
goModCache := filepath.Join(os.Getenv("HOME"), "go", "pkg", "mod")
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"--platform", "linux/amd64",
"-v", wd+":/work",
"-v", goCache+":/root/.cache/go-build",
"-v", goModCache+":/go/pkg/mod",
"-w", "/work",
goDockerImage,
"go", "build", "-ldflags", "-s -w",
"-o", containerOut,
"./cmd/kandev",
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker build kandev: %w", err)
}
// Copy from temp path (inside /work mount) to the desired output path.
// os.Rename fails with EXDEV when hostTmp and out are on different filesystems
// (e.g. the repo is on a regular disk and out is under os.TempDir() on tmpfs).
hostTmp := filepath.Join(wd, "bin", "kandev-preview-build")
defer func() { _ = os.Remove(hostTmp) }()
return copyFile(hostTmp, out, 0o755)
}
func copyFile(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}
// buildWeb runs the Vite production build. When skipInstall is true the
// pnpm install step is skipped (CI already runs it before invoking the CLI).
func buildWeb(ctx context.Context, skipInstall bool) error {
steps := []struct {
desc string
args []string
}{
{"pnpm install", []string{"pnpm", "install", "--frozen-lockfile"}},
{"pnpm build web", []string{"pnpm", "--filter", "@kandev/web", "build"}},
}
for _, step := range steps {
if skipInstall && step.desc == "pnpm install" {
continue
}
fmt.Fprintf(os.Stderr, " %s\n", step.desc)
cmd := exec.CommandContext(ctx, step.args[0], step.args[1:]...)
cmd.Dir = appsDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %w", step.desc, err)
}
}
return nil
}
// packageBundle creates a tar.gz matching the Docker container layout:
//
// app/apps/backend/bin/{kandev,agentctl,mock-agent}
// app/apps/web/dist/ (Vite SPA assets)
// usr/local/bin/kandev (native launcher symlink)
func packageBundle(binDir, tarPath string) error {
f, err := os.Create(tarPath)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
// Add binaries.
binEntries, err := os.ReadDir(binDir)
if err != nil {
return fmt.Errorf("read bin dir: %w", err)
}
for _, e := range binEntries {
src := filepath.Join(binDir, e.Name())
dst := filepath.Join("app", "apps", "backend", "bin", e.Name())
if err := addFileToTar(tw, src, dst, 0o755); err != nil {
return err
}
}
// Add Vite SPA assets. `kandev start` resolves these from /app/apps/web/dist
// and passes KANDEV_WEB_DIST_DIR to the backend.
webDistDir := filepath.Join(appsDir, "web", "dist")
if err := validateViteWebDist(webDistDir); err != nil {
return err
}
if err := addDirToTar(tw, webDistDir, filepath.Join("app", "apps", "web", "dist")); err != nil {
return fmt.Errorf("add web dist: %w", err)
}
if err := addSymlinkToTar(tw, filepath.Join("usr", "local", "bin", "kandev"), filepath.Join("/", "app", "apps", "backend", "bin", "kandev")); err != nil {
return fmt.Errorf("add kandev symlink: %w", err)
}
// Close in order: tar → gzip → file (flush compressed data).
if err := tw.Close(); err != nil {
return fmt.Errorf("close tar: %w", err)
}
return gz.Close()
}
func validateViteWebDist(webDistDir string) error {
indexPath := filepath.Join(webDistDir, "index.html")
indexHTML, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("read web dist index: %w", err)
}
if !viteIndexHasEntrypoint(string(indexHTML)) {
return fmt.Errorf("web dist index %s is missing Vite module entrypoint", indexPath)
}
return nil
}
var (
scriptTagPattern = regexp.MustCompile(`(?is)<script\b[^>]*>`)
moduleTypeAttrPattern = regexp.MustCompile(`(?is)\btype\s*=\s*["']module["']`)
assetSrcAttrPattern = regexp.MustCompile(`(?is)\bsrc\s*=\s*["']/assets/`)
)
func viteIndexHasEntrypoint(indexHTML string) bool {
for _, tag := range scriptTagPattern.FindAllString(indexHTML, -1) {
if moduleTypeAttrPattern.MatchString(tag) && assetSrcAttrPattern.MatchString(tag) {
return true
}
}
return false
}
func addSymlinkToTar(tw *tar.Writer, name, target string) error {
return tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeSymlink,
Name: name,
Linkname: target,
Mode: 0o755,
})
}
func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read %s: %w", src, err)
}
hdr := &tar.Header{
Name: dst,
Mode: int64(mode),
Size: int64(len(data)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err = tw.Write(data)
return err
}
func addDirToTar(tw *tar.Writer, srcDir, dstPrefix string) error {
return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dst := filepath.Join(dstPrefix, rel)
info, err := d.Info()
if err != nil {
return err
}
// Preserve symlinks as-is.
if info.Mode()&fs.ModeSymlink != 0 {
target, err := os.Readlink(path)
if err != nil {
return err
}
hdr := &tar.Header{
Typeflag: tar.TypeSymlink,
Name: dst,
Linkname: target,
}
return tw.WriteHeader(hdr)
}
if d.IsDir() {
return tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeDir,
Name: dst + "/",
Mode: 0o755,
})
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = dst
f, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err = io.Copy(tw, f)
return err
})
}