|
| 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 | + |
0 commit comments