Skip to content

Commit ee1668e

Browse files
authored
Initial Sugapack buildkit frontend (#1)
1 parent 9e8f666 commit ee1668e

File tree

17 files changed

+1334
-0
lines changed

17 files changed

+1334
-0
lines changed

.github/workflows/publish.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Publish
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
pull_request:
8+
branches: [main]
9+
10+
env:
11+
IMAGE: ghcr.io/nitrictech/sugapack
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-go@v5
19+
with:
20+
go-version: "1.26"
21+
- run: go test ./... -v
22+
23+
publish:
24+
needs: test
25+
runs-on: ubuntu-latest
26+
if: github.event_name == 'push'
27+
permissions:
28+
contents: read
29+
packages: write
30+
steps:
31+
- uses: actions/checkout@v4
32+
33+
- uses: docker/setup-buildx-action@v3
34+
35+
- uses: docker/login-action@v3
36+
with:
37+
registry: ghcr.io
38+
username: ${{ github.actor }}
39+
password: ${{ secrets.GITHUB_TOKEN }}
40+
41+
- uses: docker/metadata-action@v5
42+
id: meta
43+
with:
44+
images: ${{ env.IMAGE }}
45+
tags: |
46+
type=ref,event=branch
47+
type=semver,pattern={{version}}
48+
type=semver,pattern={{major}}.{{minor}}
49+
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
50+
type=sha
51+
52+
- uses: docker/build-push-action@v6
53+
with:
54+
context: .
55+
push: true
56+
tags: ${{ steps.meta.outputs.tags }}
57+
labels: ${{ steps.meta.outputs.labels }}
58+
cache-from: type=gha
59+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@
1313

1414
# Dependency directories (remove the comment below to include it)
1515
# vendor/
16+
17+
# Built binary
18+
sugapack

Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM golang:1.26-alpine AS builder
2+
3+
WORKDIR /build
4+
COPY go.mod go.sum ./
5+
RUN go mod download
6+
COPY . .
7+
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /sugapack .
8+
9+
FROM debian:bookworm-slim
10+
RUN apt-get update && apt-get install -y --no-install-recommends \
11+
ca-certificates curl git && \
12+
rm -rf /var/lib/apt/lists/*
13+
COPY --from=builder /sugapack /bin/sugapack
14+
ENTRYPOINT ["/bin/sugapack"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Nitric Technologies
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
.PHONY: build image test clean infra infra-stop dry-run dry-run-private
2+
3+
# Internal registry hostname (as seen by buildkitd inside the Docker network)
4+
IMAGE := sugapack-registry:5000/sugapack:local
5+
BUILDKITD := sugapack-buildkitd
6+
REGISTRY_C := sugapack-registry
7+
NETWORK := sugapack-net
8+
SOCK := /tmp/sugapack-buildkit/buildkitd.sock
9+
ADDR := unix://$(SOCK)
10+
TEST_CONFIG ?= test.json
11+
12+
# ── Local dev ────────────────────────────────────────────────
13+
14+
build:
15+
go build -o sugapack .
16+
17+
test:
18+
go test ./... -v
19+
20+
# ── Docker / BuildKit infra ──────────────────────────────────
21+
22+
# Start local registry + buildkitd on a shared network
23+
infra:
24+
@docker network inspect $(NETWORK) >/dev/null 2>&1 || \
25+
docker network create $(NETWORK)
26+
@if ! docker inspect $(REGISTRY_C) >/dev/null 2>&1; then \
27+
echo "Starting registry..."; \
28+
docker run -d --name $(REGISTRY_C) --network $(NETWORK) registry:2; \
29+
elif [ "$$(docker inspect -f '{{.State.Running}}' $(REGISTRY_C))" != "true" ]; then \
30+
docker start $(REGISTRY_C); \
31+
else \
32+
echo "registry already running"; \
33+
fi
34+
@mkdir -p $(dir $(SOCK))
35+
@if ! docker inspect $(BUILDKITD) >/dev/null 2>&1; then \
36+
echo "Starting buildkitd..."; \
37+
docker run -d --name $(BUILDKITD) --privileged \
38+
--network $(NETWORK) \
39+
-v $(dir $(SOCK)):/run/buildkit \
40+
-v $(CURDIR)/buildkitd.toml:/etc/buildkit/buildkitd.toml:ro \
41+
moby/buildkit:latest \
42+
--addr unix:///run/buildkit/buildkitd.sock \
43+
--group $(shell id -g); \
44+
elif [ "$$(docker inspect -f '{{.State.Running}}' $(BUILDKITD))" != "true" ]; then \
45+
docker start $(BUILDKITD); \
46+
else \
47+
echo "buildkitd already running"; \
48+
fi
49+
@echo "Waiting for buildkitd..."
50+
@for i in 1 2 3 4 5; do \
51+
buildctl --addr $(ADDR) debug workers >/dev/null 2>&1 && break; \
52+
sleep 1; \
53+
done
54+
55+
infra-stop:
56+
docker rm -f $(BUILDKITD) $(REGISTRY_C) 2>/dev/null || true
57+
docker network rm $(NETWORK) 2>/dev/null || true
58+
rm -rf $(dir $(SOCK))
59+
60+
# Build and push the single image (frontend + railpack CLI) to local registry
61+
image: infra
62+
buildctl --addr $(ADDR) build \
63+
--frontend dockerfile.v0 \
64+
--local context=. \
65+
--local dockerfile=. \
66+
--output type=image,name=$(IMAGE),push=true,registry.insecure=true
67+
68+
# ── Dry-run ──────────────────────────────────────────────────
69+
#
70+
# make dry-run # public repo, uses test.json
71+
# make dry-run TEST_CONFIG=myapp.json # public repo, custom config
72+
# GIT_AUTH_TOKEN=ghp_xxx make dry-run-private # private repo
73+
# GIT_AUTH_TOKEN=ghp_xxx make dry-run-private TEST_CONFIG=private.json
74+
75+
dry-run: image
76+
buildctl --addr $(ADDR) build \
77+
--frontend gateway.v0 \
78+
--opt source=$(IMAGE) \
79+
--local dockerfile=. \
80+
--opt filename=$(TEST_CONFIG) \
81+
--output type=docker,name=sugapack-test-output | docker load
82+
83+
dry-run-private: image
84+
buildctl --addr $(ADDR) build \
85+
--frontend gateway.v0 \
86+
--opt source=$(IMAGE) \
87+
--local dockerfile=. \
88+
--opt filename=$(TEST_CONFIG) \
89+
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
90+
--output type=docker,name=sugapack-test-output | docker load
91+
92+
# ── Cleanup ──────────────────────────────────────────────────
93+
94+
clean: infra-stop
95+
rm -f sugapack

README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,98 @@
11
# sugapack
2+
3+
A BuildKit frontend that wraps [railpack](https://github.com/railwayapp/railpack) with remote git source support. Everything runs on the builder — zero local context transfer.
4+
5+
## How it works
6+
7+
Sugapack is a BuildKit gRPC frontend that accepts a small JSON config as input (not the full source):
8+
9+
1. **Fetch source** — clones the repo via `llb.Git()` with optional token auth for private repos
10+
2. **Generate plan** — runs railpack's plan generation (embedded as a Go library) on the fetched source
11+
3. **Execute plan** — converts the plan to LLB using railpack's `build_llb` package, substituting the git source for local context
12+
13+
## Usage
14+
15+
### With Depot
16+
17+
```bash
18+
echo '{"repo":"https://github.com/user/repo.git","ref":"abc123"}' | \
19+
depot build -f - \
20+
--build-arg BUILDKIT_SYNTAX=ghcr.io/nitrictech/sugapack:latest \
21+
--save --platform linux/amd64 \
22+
/dev/null
23+
```
24+
25+
### With buildctl
26+
27+
```bash
28+
buildctl build \
29+
--frontend gateway.v0 \
30+
--opt source=ghcr.io/nitrictech/sugapack:latest \
31+
--local dockerfile=. \
32+
--opt filename=config.json \
33+
--output type=image,name=my-app:latest
34+
```
35+
36+
### Private repos
37+
38+
Pass a git auth token as a BuildKit secret:
39+
40+
```bash
41+
echo '{"repo":"https://github.com/user/private-repo.git","ref":"main","authSecret":"GIT_AUTH_TOKEN"}' | \
42+
depot build -f - \
43+
--build-arg BUILDKIT_SYNTAX=ghcr.io/nitrictech/sugapack:latest \
44+
--secret id=GIT_AUTH_TOKEN \
45+
--save --platform linux/amd64 \
46+
/dev/null
47+
```
48+
49+
## Config format
50+
51+
The "Dockerfile" input is a JSON config:
52+
53+
```json
54+
{
55+
"repo": "https://github.com/user/repo.git",
56+
"ref": "abc123def",
57+
"context": "apps/web",
58+
"authSecret": "GIT_AUTH_TOKEN",
59+
"railpack": {
60+
"buildCmd": "npm run build",
61+
"startCmd": "npm start",
62+
"envs": {
63+
"NODE_ENV": "production"
64+
}
65+
}
66+
}
67+
```
68+
69+
| Field | Required | Description |
70+
|-------|----------|-------------|
71+
| `repo` | yes | Git repository URL (HTTPS) |
72+
| `ref` | no | Commit SHA, branch, or tag (default: `main`) |
73+
| `context` | no | Subdirectory within the repo to use as build context |
74+
| `authSecret` | no | BuildKit secret ID containing a git auth token |
75+
| `railpack.buildCmd` | no | Override the build command |
76+
| `railpack.startCmd` | no | Override the start command |
77+
| `railpack.envs` | no | Additional environment variables for plan generation |
78+
79+
## Local development
80+
81+
Requires Docker and [buildctl](https://github.com/moby/buildkit).
82+
83+
```bash
84+
# Run tests
85+
make test
86+
87+
# Start local infra (buildkitd + registry) and run a full build
88+
make dry-run
89+
90+
# Custom config
91+
make dry-run TEST_CONFIG=myapp.json
92+
93+
# Private repo
94+
GIT_AUTH_TOKEN=ghp_xxx make dry-run-private
95+
96+
# Tear down
97+
make clean
98+
```

buildkitd.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[registry."sugapack-registry:5000"]
2+
http = true
3+
insecure = true

config.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package main
2+
3+
// Config is the JSON document passed as the "Dockerfile" input to the frontend.
4+
// It tells the frontend where to fetch source and how to configure railpack.
5+
type Config struct {
6+
// Git repository URL (HTTPS)
7+
Repo string `json:"repo"`
8+
// Git ref (commit SHA, branch, or tag)
9+
Ref string `json:"ref"`
10+
// Subdirectory within the repo to use as build context
11+
Context string `json:"context,omitempty"`
12+
// BuildKit secret ID containing the git auth token for private repos
13+
AuthSecret string `json:"authSecret,omitempty"`
14+
// Railpack-specific configuration
15+
Railpack RailpackConfig `json:"railpack,omitempty"`
16+
}
17+
18+
// RailpackConfig holds railpack plan generation overrides.
19+
type RailpackConfig struct {
20+
BuildCmd string `json:"buildCmd,omitempty"`
21+
StartCmd string `json:"startCmd,omitempty"`
22+
Envs map[string]string `json:"envs,omitempty"`
23+
}

config_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestConfigParsing(t *testing.T) {
9+
input := `{
10+
"repo": "https://github.com/user/repo.git",
11+
"ref": "abc123def",
12+
"context": "apps/web",
13+
"authSecret": "GIT_AUTH_TOKEN",
14+
"railpack": {
15+
"buildCmd": "npm run build",
16+
"startCmd": "npm start",
17+
"envs": {"NODE_ENV": "production"}
18+
}
19+
}`
20+
21+
var config Config
22+
if err := json.Unmarshal([]byte(input), &config); err != nil {
23+
t.Fatalf("failed to parse config: %v", err)
24+
}
25+
26+
if config.Repo != "https://github.com/user/repo.git" {
27+
t.Errorf("repo = %q, want %q", config.Repo, "https://github.com/user/repo.git")
28+
}
29+
if config.Ref != "abc123def" {
30+
t.Errorf("ref = %q, want %q", config.Ref, "abc123def")
31+
}
32+
if config.Context != "apps/web" {
33+
t.Errorf("context = %q, want %q", config.Context, "apps/web")
34+
}
35+
if config.AuthSecret != "GIT_AUTH_TOKEN" {
36+
t.Errorf("authSecret = %q, want %q", config.AuthSecret, "GIT_AUTH_TOKEN")
37+
}
38+
if config.Railpack.BuildCmd != "npm run build" {
39+
t.Errorf("railpack.buildCmd = %q, want %q", config.Railpack.BuildCmd, "npm run build")
40+
}
41+
if config.Railpack.StartCmd != "npm start" {
42+
t.Errorf("railpack.startCmd = %q, want %q", config.Railpack.StartCmd, "npm start")
43+
}
44+
if config.Railpack.Envs["NODE_ENV"] != "production" {
45+
t.Errorf("railpack.envs[NODE_ENV] = %q, want %q", config.Railpack.Envs["NODE_ENV"], "production")
46+
}
47+
}
48+
49+
func TestConfigMinimal(t *testing.T) {
50+
input := `{"repo": "https://github.com/user/repo.git"}`
51+
52+
var config Config
53+
if err := json.Unmarshal([]byte(input), &config); err != nil {
54+
t.Fatalf("failed to parse config: %v", err)
55+
}
56+
57+
if config.Repo != "https://github.com/user/repo.git" {
58+
t.Errorf("repo = %q, want %q", config.Repo, "https://github.com/user/repo.git")
59+
}
60+
if config.Ref != "" {
61+
t.Errorf("ref = %q, want empty", config.Ref)
62+
}
63+
if config.Context != "" {
64+
t.Errorf("context = %q, want empty", config.Context)
65+
}
66+
if config.AuthSecret != "" {
67+
t.Errorf("authSecret = %q, want empty", config.AuthSecret)
68+
}
69+
}

0 commit comments

Comments
 (0)