Skip to content

Commit efa9dec

Browse files
committed
poc: add build system for source-to-image builds
Proof of concept for a secure build system that runs rootless BuildKit inside ephemeral Cloud Hypervisor microVMs for multi-tenant isolation. Components: - lib/builds/: Core build system (queue, storage, manager, cache) - lib/builds/builder_agent/: Guest binary for running BuildKit - lib/builds/templates/: Dockerfile generation for Node.js/Python - lib/builds/images/: Builder image Dockerfiles API endpoints: - POST /v1/builds: Submit build job - GET /v1/builds: List builds - GET /v1/builds/{id}: Get build details - DELETE /v1/builds/{id}: Cancel build - GET /v1/builds/{id}/logs: Stream logs (SSE)
1 parent 4b0c8f3 commit efa9dec

File tree

28 files changed

+5664
-258
lines changed

28 files changed

+5664
-258
lines changed

cmd/api/api/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"github.com/onkernel/hypeman/cmd/api/config"
5+
"github.com/onkernel/hypeman/lib/builds"
56
"github.com/onkernel/hypeman/lib/devices"
67
"github.com/onkernel/hypeman/lib/images"
78
"github.com/onkernel/hypeman/lib/ingress"
@@ -20,6 +21,7 @@ type ApiService struct {
2021
NetworkManager network.Manager
2122
DeviceManager devices.Manager
2223
IngressManager ingress.Manager
24+
BuildManager builds.Manager
2325
}
2426

2527
var _ oapi.StrictServerInterface = (*ApiService)(nil)
@@ -33,6 +35,7 @@ func New(
3335
networkManager network.Manager,
3436
deviceManager devices.Manager,
3537
ingressManager ingress.Manager,
38+
buildManager builds.Manager,
3639
) *ApiService {
3740
return &ApiService{
3841
Config: config,
@@ -42,5 +45,6 @@ func New(
4245
NetworkManager: networkManager,
4346
DeviceManager: deviceManager,
4447
IngressManager: ingressManager,
48+
BuildManager: buildManager,
4549
}
4650
}

cmd/api/api/builds.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"io"
8+
"strconv"
9+
10+
"github.com/onkernel/hypeman/lib/builds"
11+
"github.com/onkernel/hypeman/lib/logger"
12+
"github.com/onkernel/hypeman/lib/oapi"
13+
)
14+
15+
// ListBuilds returns all builds
16+
func (s *ApiService) ListBuilds(ctx context.Context, request oapi.ListBuildsRequestObject) (oapi.ListBuildsResponseObject, error) {
17+
log := logger.FromContext(ctx)
18+
19+
domainBuilds, err := s.BuildManager.ListBuilds(ctx)
20+
if err != nil {
21+
log.ErrorContext(ctx, "failed to list builds", "error", err)
22+
return oapi.ListBuilds500JSONResponse{
23+
Code: "internal_error",
24+
Message: "failed to list builds",
25+
}, nil
26+
}
27+
28+
oapiBuilds := make([]oapi.Build, len(domainBuilds))
29+
for i, b := range domainBuilds {
30+
oapiBuilds[i] = buildToOAPI(b)
31+
}
32+
33+
return oapi.ListBuilds200JSONResponse(oapiBuilds), nil
34+
}
35+
36+
// CreateBuild creates a new build job
37+
func (s *ApiService) CreateBuild(ctx context.Context, request oapi.CreateBuildRequestObject) (oapi.CreateBuildResponseObject, error) {
38+
log := logger.FromContext(ctx)
39+
40+
// Parse multipart form fields
41+
var sourceData []byte
42+
var runtime string
43+
var baseImageDigest, cacheScope, dockerfile string
44+
var timeoutSeconds int
45+
46+
for {
47+
part, err := request.Body.NextPart()
48+
if err == io.EOF {
49+
break
50+
}
51+
if err != nil {
52+
return oapi.CreateBuild400JSONResponse{
53+
Code: "invalid_request",
54+
Message: "failed to parse multipart form",
55+
}, nil
56+
}
57+
58+
switch part.FormName() {
59+
case "source":
60+
sourceData, err = io.ReadAll(part)
61+
if err != nil {
62+
return oapi.CreateBuild400JSONResponse{
63+
Code: "invalid_source",
64+
Message: "failed to read source data",
65+
}, nil
66+
}
67+
case "runtime":
68+
var buf bytes.Buffer
69+
io.Copy(&buf, part)
70+
runtime = buf.String()
71+
case "base_image_digest":
72+
var buf bytes.Buffer
73+
io.Copy(&buf, part)
74+
baseImageDigest = buf.String()
75+
case "cache_scope":
76+
var buf bytes.Buffer
77+
io.Copy(&buf, part)
78+
cacheScope = buf.String()
79+
case "dockerfile":
80+
var buf bytes.Buffer
81+
io.Copy(&buf, part)
82+
dockerfile = buf.String()
83+
case "timeout_seconds":
84+
var buf bytes.Buffer
85+
io.Copy(&buf, part)
86+
if v, err := strconv.Atoi(buf.String()); err == nil {
87+
timeoutSeconds = v
88+
}
89+
}
90+
part.Close()
91+
}
92+
93+
if runtime == "" {
94+
return oapi.CreateBuild400JSONResponse{
95+
Code: "invalid_request",
96+
Message: "runtime is required",
97+
}, nil
98+
}
99+
100+
if len(sourceData) == 0 {
101+
return oapi.CreateBuild400JSONResponse{
102+
Code: "invalid_request",
103+
Message: "source is required",
104+
}, nil
105+
}
106+
107+
// Build domain request
108+
domainReq := builds.CreateBuildRequest{
109+
Runtime: runtime,
110+
BaseImageDigest: baseImageDigest,
111+
CacheScope: cacheScope,
112+
Dockerfile: dockerfile,
113+
}
114+
115+
// Apply timeout if provided
116+
if timeoutSeconds > 0 {
117+
domainReq.BuildPolicy = &builds.BuildPolicy{
118+
TimeoutSeconds: timeoutSeconds,
119+
}
120+
}
121+
122+
build, err := s.BuildManager.CreateBuild(ctx, domainReq, sourceData)
123+
if err != nil {
124+
switch {
125+
case errors.Is(err, builds.ErrInvalidRuntime):
126+
return oapi.CreateBuild400JSONResponse{
127+
Code: "invalid_runtime",
128+
Message: err.Error(),
129+
}, nil
130+
case errors.Is(err, builds.ErrInvalidSource):
131+
return oapi.CreateBuild400JSONResponse{
132+
Code: "invalid_source",
133+
Message: err.Error(),
134+
}, nil
135+
default:
136+
log.ErrorContext(ctx, "failed to create build", "error", err)
137+
return oapi.CreateBuild500JSONResponse{
138+
Code: "internal_error",
139+
Message: "failed to create build",
140+
}, nil
141+
}
142+
}
143+
144+
return oapi.CreateBuild202JSONResponse(buildToOAPI(build)), nil
145+
}
146+
147+
// GetBuild gets build details
148+
func (s *ApiService) GetBuild(ctx context.Context, request oapi.GetBuildRequestObject) (oapi.GetBuildResponseObject, error) {
149+
log := logger.FromContext(ctx)
150+
151+
build, err := s.BuildManager.GetBuild(ctx, request.Id)
152+
if err != nil {
153+
if errors.Is(err, builds.ErrNotFound) {
154+
return oapi.GetBuild404JSONResponse{
155+
Code: "not_found",
156+
Message: "build not found",
157+
}, nil
158+
}
159+
log.ErrorContext(ctx, "failed to get build", "error", err, "id", request.Id)
160+
return oapi.GetBuild500JSONResponse{
161+
Code: "internal_error",
162+
Message: "failed to get build",
163+
}, nil
164+
}
165+
166+
return oapi.GetBuild200JSONResponse(buildToOAPI(build)), nil
167+
}
168+
169+
// CancelBuild cancels a build
170+
func (s *ApiService) CancelBuild(ctx context.Context, request oapi.CancelBuildRequestObject) (oapi.CancelBuildResponseObject, error) {
171+
log := logger.FromContext(ctx)
172+
173+
err := s.BuildManager.CancelBuild(ctx, request.Id)
174+
if err != nil {
175+
switch {
176+
case errors.Is(err, builds.ErrNotFound):
177+
return oapi.CancelBuild404JSONResponse{
178+
Code: "not_found",
179+
Message: "build not found",
180+
}, nil
181+
case errors.Is(err, builds.ErrBuildInProgress):
182+
return oapi.CancelBuild409JSONResponse{
183+
Code: "conflict",
184+
Message: "build already in progress",
185+
}, nil
186+
default:
187+
log.ErrorContext(ctx, "failed to cancel build", "error", err, "id", request.Id)
188+
return oapi.CancelBuild500JSONResponse{
189+
Code: "internal_error",
190+
Message: "failed to cancel build",
191+
}, nil
192+
}
193+
}
194+
195+
return oapi.CancelBuild204Response{}, nil
196+
}
197+
198+
// GetBuildLogs streams build logs
199+
func (s *ApiService) GetBuildLogs(ctx context.Context, request oapi.GetBuildLogsRequestObject) (oapi.GetBuildLogsResponseObject, error) {
200+
log := logger.FromContext(ctx)
201+
202+
logs, err := s.BuildManager.GetBuildLogs(ctx, request.Id)
203+
if err != nil {
204+
if errors.Is(err, builds.ErrNotFound) {
205+
return oapi.GetBuildLogs404JSONResponse{
206+
Code: "not_found",
207+
Message: "build not found",
208+
}, nil
209+
}
210+
log.ErrorContext(ctx, "failed to get build logs", "error", err, "id", request.Id)
211+
return oapi.GetBuildLogs500JSONResponse{
212+
Code: "internal_error",
213+
Message: "failed to get build logs",
214+
}, nil
215+
}
216+
217+
// Return logs as SSE
218+
// For simplicity, return all logs at once
219+
// TODO: Implement proper SSE streaming with follow support
220+
return oapi.GetBuildLogs200TexteventStreamResponse{
221+
Body: stringReader(string(logs)),
222+
ContentLength: int64(len(logs)),
223+
}, nil
224+
}
225+
226+
// buildToOAPI converts a domain Build to OAPI Build
227+
func buildToOAPI(b *builds.Build) oapi.Build {
228+
oapiBuild := oapi.Build{
229+
Id: b.ID,
230+
Status: oapi.BuildStatus(b.Status),
231+
Runtime: b.Runtime,
232+
QueuePosition: b.QueuePosition,
233+
ImageDigest: b.ImageDigest,
234+
ImageRef: b.ImageRef,
235+
Error: b.Error,
236+
CreatedAt: b.CreatedAt,
237+
StartedAt: b.StartedAt,
238+
CompletedAt: b.CompletedAt,
239+
DurationMs: b.DurationMS,
240+
}
241+
242+
if b.Provenance != nil {
243+
oapiBuild.Provenance = &oapi.BuildProvenance{
244+
BaseImageDigest: &b.Provenance.BaseImageDigest,
245+
SourceHash: &b.Provenance.SourceHash,
246+
ToolchainVersion: &b.Provenance.ToolchainVersion,
247+
BuildkitVersion: &b.Provenance.BuildkitVersion,
248+
Timestamp: &b.Provenance.Timestamp,
249+
}
250+
if len(b.Provenance.LockfileHashes) > 0 {
251+
oapiBuild.Provenance.LockfileHashes = &b.Provenance.LockfileHashes
252+
}
253+
}
254+
255+
return oapiBuild
256+
}
257+
258+
// deref safely dereferences a pointer, returning empty string if nil
259+
func deref(s *string) string {
260+
if s == nil {
261+
return ""
262+
}
263+
return *s
264+
}
265+
266+
// stringReader wraps a string as an io.Reader
267+
type stringReaderImpl struct {
268+
s string
269+
i int
270+
}
271+
272+
func stringReader(s string) io.Reader {
273+
return &stringReaderImpl{s: s}
274+
}
275+
276+
func (r *stringReaderImpl) Read(p []byte) (n int, err error) {
277+
if r.i >= len(r.s) {
278+
return 0, io.EOF
279+
}
280+
n = copy(p, r.s[r.i:])
281+
r.i += n
282+
return n, nil
283+
}
284+

cmd/api/api/registry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ func TestRegistryTagPush(t *testing.T) {
373373
for _, img := range images {
374374
if img.Digest == digest.String() {
375375
found = true
376-
assert.Equal(t, oapi.Ready, img.Status, "image in list should have Ready status")
376+
assert.Equal(t, oapi.ImageStatusReady, img.Status, "image in list should have Ready status")
377377
assert.NotNil(t, img.SizeBytes, "ready image should have size")
378378
t.Logf("Image found in ListImages: %s (status=%s, size=%d)", img.Name, img.Status, *img.SizeBytes)
379379
break

cmd/api/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ type Config struct {
101101

102102
// Cloudflare configuration (if AcmeDnsProvider=cloudflare)
103103
CloudflareApiToken string // Cloudflare API token
104+
105+
// Build system configuration
106+
MaxConcurrentSourceBuilds int // Max concurrent source-to-image builds
107+
BuilderImage string // OCI image for builder VMs
108+
RegistryURL string // URL of registry for built images
109+
BuildTimeout int // Default build timeout in seconds
104110
}
105111

106112
// Load loads configuration from environment variables
@@ -163,6 +169,12 @@ func Load() *Config {
163169

164170
// Cloudflare configuration
165171
CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""),
172+
173+
// Build system configuration
174+
MaxConcurrentSourceBuilds: getEnvInt("MAX_CONCURRENT_SOURCE_BUILDS", 2),
175+
BuilderImage: getEnv("BUILDER_IMAGE", "hypeman/builder:latest"),
176+
RegistryURL: getEnv("REGISTRY_URL", "localhost:8080"),
177+
BuildTimeout: getEnvInt("BUILD_TIMEOUT", 600),
166178
}
167179

168180
return cfg

cmd/api/wire.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/google/wire"
1010
"github.com/onkernel/hypeman/cmd/api/api"
1111
"github.com/onkernel/hypeman/cmd/api/config"
12+
"github.com/onkernel/hypeman/lib/builds"
1213
"github.com/onkernel/hypeman/lib/devices"
1314
"github.com/onkernel/hypeman/lib/images"
1415
"github.com/onkernel/hypeman/lib/ingress"
@@ -32,6 +33,7 @@ type application struct {
3233
InstanceManager instances.Manager
3334
VolumeManager volumes.Manager
3435
IngressManager ingress.Manager
36+
BuildManager builds.Manager
3537
Registry *registry.Registry
3638
ApiService *api.ApiService
3739
}
@@ -50,6 +52,7 @@ func initializeApp() (*application, func(), error) {
5052
providers.ProvideInstanceManager,
5153
providers.ProvideVolumeManager,
5254
providers.ProvideIngressManager,
55+
providers.ProvideBuildManager,
5356
providers.ProvideRegistry,
5457
api.New,
5558
wire.Struct(new(application), "*"),

0 commit comments

Comments
 (0)