Skip to content

Commit 51b9893

Browse files
Add API, CLI and web UI integration for objects
This change adds the API endpoints, the CLI commands and the web UI elements needed to manage objects in GARMs internal storage. This storage system is meant to be used to distribute the garm-agent and as a single source of truth for provider binaries, when we will add the ability for GARM to scale out. Potentially, we can also use this in air gapped systems to distribute the runner binaries for forges that don't have their own internal storage system (like GHES). Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
1 parent f66f95b commit 51b9893

File tree

138 files changed

+7911
-267
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

138 files changed

+7911
-267
lines changed
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
// Copyright 2025 Cloudbase Solutions SRL
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
// not use this file except in compliance with the License. You may obtain
5+
// a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
// License for the specific language governing permissions and limitations
13+
// under the License.
14+
package controllers
15+
16+
import (
17+
"encoding/json"
18+
"fmt"
19+
"io"
20+
"log/slog"
21+
"math"
22+
"net/http"
23+
"strconv"
24+
"strings"
25+
26+
"github.com/gorilla/mux"
27+
28+
gErrors "github.com/cloudbase/garm-provider-common/errors"
29+
"github.com/cloudbase/garm/params"
30+
)
31+
32+
// swagger:route GET /objects objects ListFileObjects
33+
//
34+
// List file objects.
35+
//
36+
// Parameters:
37+
// + name: tags
38+
// description: List of tags to filter by.
39+
// type: array
40+
// items:
41+
// type: string
42+
// in: query
43+
// required: false
44+
// + name: page
45+
// description: The page at which to list.
46+
// type: integer
47+
// in: query
48+
// required: false
49+
// + name: pageSize
50+
// description: Number of items per page.
51+
// type: integer
52+
// in: query
53+
// required: false
54+
//
55+
// Responses:
56+
// 200: FileObjectPaginatedResponse
57+
// 400: APIErrorResponse
58+
func (a *APIController) ListFileObjects(w http.ResponseWriter, r *http.Request) {
59+
ctx := r.Context()
60+
var pageLocation int64
61+
var pageSize int64 = 25
62+
tags := r.URL.Query().Get("tags")
63+
pageArg := r.URL.Query().Get("page")
64+
pageSizeArg := r.URL.Query().Get("pageSize")
65+
66+
if pageArg != "" {
67+
pageInt, err := strconv.ParseInt(pageArg, 10, 64)
68+
if err == nil && pageInt >= 0 {
69+
pageLocation = pageInt
70+
}
71+
}
72+
if pageSizeArg != "" {
73+
pageSizeInt, err := strconv.ParseInt(pageSizeArg, 10, 64)
74+
if err == nil && pageSizeInt >= 0 {
75+
pageSize = pageSizeInt
76+
}
77+
}
78+
parsedTags := parseTagsArg(tags)
79+
files, err := a.r.ListFileObjects(ctx, uint64(pageLocation), uint64(pageSize), parsedTags)
80+
if err != nil {
81+
handleError(ctx, w, err)
82+
return
83+
}
84+
85+
w.Header().Set("Content-Type", "application/json")
86+
if err := json.NewEncoder(w).Encode(files); err != nil {
87+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
88+
}
89+
}
90+
91+
// swagger:route DELETE /objects/{objectID} objects DeleteFileObject
92+
//
93+
// Delete a file object.
94+
//
95+
// Parameters:
96+
// + name: objectID
97+
// description: The ID of the file object.
98+
// type: string
99+
// in: path
100+
// required: true
101+
//
102+
// Responses:
103+
// default: APIErrorResponse
104+
func (a *APIController) DeleteFileObject(w http.ResponseWriter, r *http.Request) {
105+
ctx := r.Context()
106+
107+
vars := mux.Vars(r)
108+
objectID, err := getObjectIDFromVars(vars)
109+
if err != nil {
110+
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
111+
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
112+
return
113+
}
114+
115+
if err := a.r.DeleteFileObject(ctx, objectID); err != nil {
116+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to delete file object")
117+
handleError(ctx, w, err)
118+
return
119+
}
120+
w.WriteHeader(http.StatusNoContent)
121+
}
122+
123+
// swagger:route GET /objects/{objectID} objects GetFileObject
124+
//
125+
// Get a file object.
126+
//
127+
// Parameters:
128+
// + name: objectID
129+
// description: The ID of the file object.
130+
// type: string
131+
// in: path
132+
// required: true
133+
//
134+
// Responses:
135+
// 200: FileObject
136+
// 400: APIErrorResponse
137+
func (a *APIController) GetFileObject(w http.ResponseWriter, r *http.Request) {
138+
ctx := r.Context()
139+
140+
vars := mux.Vars(r)
141+
objectID, err := getObjectIDFromVars(vars)
142+
if err != nil {
143+
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
144+
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
145+
return
146+
}
147+
148+
file, err := a.r.GetFileObject(ctx, objectID)
149+
if err != nil {
150+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to get file object")
151+
handleError(ctx, w, err)
152+
return
153+
}
154+
w.Header().Set("Content-Type", "application/json")
155+
if err := json.NewEncoder(w).Encode(file); err != nil {
156+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
157+
}
158+
}
159+
160+
// swagger:route PUT /objects/{objectID} objects UpdateFileObject
161+
//
162+
// Update a file object.
163+
//
164+
// Parameters:
165+
// + name: objectID
166+
// description: The ID of the file object.
167+
// type: string
168+
// in: path
169+
// required: true
170+
// + name: Body
171+
// description: Parameters used when updating a file object.
172+
// type: UpdateFileObjectParams
173+
// in: body
174+
// required: true
175+
//
176+
// Responses:
177+
// 200: FileObject
178+
// 400: APIErrorResponse
179+
func (a *APIController) UpdateFileObject(w http.ResponseWriter, r *http.Request) {
180+
ctx := r.Context()
181+
182+
vars := mux.Vars(r)
183+
objectID, err := getObjectIDFromVars(vars)
184+
if err != nil {
185+
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
186+
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
187+
return
188+
}
189+
190+
var param params.UpdateFileObjectParams
191+
if err := json.NewDecoder(r.Body).Decode(&param); err != nil {
192+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to decode request")
193+
handleError(ctx, w, gErrors.ErrBadRequest)
194+
return
195+
}
196+
197+
if len(param.Tags) > 0 {
198+
for idx, val := range param.Tags {
199+
param.Tags[idx] = strings.ToLower(strings.TrimSpace(val))
200+
}
201+
}
202+
203+
file, err := a.r.UpdateFileObject(ctx, objectID, param)
204+
if err != nil {
205+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to get file object")
206+
handleError(ctx, w, err)
207+
return
208+
}
209+
w.Header().Set("Content-Type", "application/json")
210+
if err := json.NewEncoder(w).Encode(file); err != nil {
211+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
212+
}
213+
}
214+
215+
func (a *APIController) CreateFileObject(w http.ResponseWriter, r *http.Request) {
216+
defer r.Body.Close()
217+
218+
ctx := r.Context()
219+
fileName := r.Header.Get("X-File-Name")
220+
if fileName == "" {
221+
handleError(ctx, w, gErrors.NewBadRequestError("missing X-File-Name header"))
222+
return
223+
}
224+
description := r.Header.Get("X-File-Description")
225+
contentLengthStr := r.Header.Get("Content-Length")
226+
if contentLengthStr == "" {
227+
handleError(ctx, w, gErrors.NewBadRequestError("missing Content-Length header in request"))
228+
return
229+
}
230+
231+
tags := r.Header.Get("X-Tags")
232+
parsedTags := parseTagsArg(tags)
233+
234+
fileSize, err := strconv.ParseInt(contentLengthStr, 10, 64)
235+
if err != nil {
236+
handleError(ctx, w, gErrors.NewBadRequestError("invalid Content-Length"))
237+
return
238+
}
239+
240+
param := params.CreateFileObjectParams{
241+
Name: fileName,
242+
Size: fileSize,
243+
Tags: parsedTags,
244+
}
245+
if len(description) > 0 {
246+
param.Description = description
247+
}
248+
249+
fileObj, err := a.r.CreateFileObject(ctx, param, r.Body)
250+
if err != nil {
251+
slog.ErrorContext(ctx, "failed to create blob", "error", err)
252+
handleError(ctx, w, err)
253+
return
254+
}
255+
w.Header().Set("Content-Type", "application/json")
256+
if err := json.NewEncoder(w).Encode(fileObj); err != nil {
257+
slog.With(slog.Any("error", err)).ErrorContext(ctx, "failed to encode response")
258+
}
259+
}
260+
261+
func (a *APIController) DownloadFileObject(w http.ResponseWriter, r *http.Request) {
262+
ctx := r.Context()
263+
264+
vars := mux.Vars(r)
265+
objectID, err := getObjectIDFromVars(vars)
266+
if err != nil {
267+
slog.ErrorContext(ctx, "failed to get object ID", "error", err)
268+
handleError(ctx, w, gErrors.NewBadRequestError("invalid objectID: %s", err))
269+
return
270+
}
271+
272+
objectDetails, err := a.r.GetFileObject(ctx, objectID)
273+
if err != nil {
274+
handleError(ctx, w, err)
275+
return
276+
}
277+
278+
objectHandle, err := a.r.GetFileObjectReader(ctx, objectID)
279+
if err != nil {
280+
handleError(ctx, w, err)
281+
return
282+
}
283+
defer objectHandle.Close()
284+
w.Header().Set("Content-Type", objectDetails.FileType)
285+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", objectDetails.Name))
286+
w.Header().Set("Content-Length", strconv.FormatInt(objectDetails.Size, 10))
287+
288+
if r.Method == http.MethodHead {
289+
return
290+
}
291+
292+
copied, err := io.Copy(w, objectHandle)
293+
if err != nil {
294+
slog.ErrorContext(ctx, "failed to stream data", "error", err)
295+
}
296+
if copied < objectDetails.Size {
297+
slog.WarnContext(ctx, "some data was not streamed", "object_id", objectDetails.ID, "object_size", objectDetails.Size, "streamed_bytes", copied)
298+
}
299+
}
300+
301+
func parseTagsArg(tags string) []string {
302+
var parsedTags []string
303+
foundTag := make(map[string]struct{})
304+
if tags != "" {
305+
tagList := strings.SplitSeq(tags, ",")
306+
for val := range tagList {
307+
if val == "" {
308+
continue
309+
}
310+
low := strings.ToLower(strings.TrimSpace(val))
311+
if _, ok := foundTag[low]; ok {
312+
continue
313+
}
314+
parsedTags = append(parsedTags, low)
315+
foundTag[low] = struct{}{}
316+
}
317+
}
318+
return parsedTags
319+
}
320+
321+
func parseAsUint(val string) (uint, error) {
322+
parsedObjID, err := strconv.ParseUint(val, 10, 64)
323+
if err != nil {
324+
return 0, fmt.Errorf("invalid object ID; must be a number")
325+
}
326+
if parsedObjID > math.MaxUint {
327+
return 0, fmt.Errorf("the object ID is too large")
328+
}
329+
return uint(parsedObjID), nil
330+
}
331+
332+
func getObjectIDFromVars(vars map[string]string) (uint, error) {
333+
objectID, ok := vars["objectID"]
334+
if !ok {
335+
return 0, fmt.Errorf("no objectID specified")
336+
}
337+
return parseAsUint(objectID)
338+
}

apiserver/routers/routers.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,28 @@ func NewAPIRouter(han *controllers.APIController, authMiddleware, initMiddleware
216216
apiRouter.Handle("/metrics-token/", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")
217217
apiRouter.Handle("/metrics-token", http.HandlerFunc(han.MetricsTokenHandler)).Methods("GET", "OPTIONS")
218218

219+
/////////////
220+
// Objects //
221+
/////////////
222+
// List objects
223+
apiRouter.Handle("/objects/", http.HandlerFunc(han.ListFileObjects)).Methods("GET", "OPTIONS")
224+
apiRouter.Handle("/objects", http.HandlerFunc(han.ListFileObjects)).Methods("GET", "OPTIONS")
225+
// Create object
226+
apiRouter.Handle("/objects/", http.HandlerFunc(han.CreateFileObject)).Methods("POST", "OPTIONS")
227+
apiRouter.Handle("/objects", http.HandlerFunc(han.CreateFileObject)).Methods("POST", "OPTIONS")
228+
// Delete object
229+
apiRouter.Handle("/objects/{objectID}/", http.HandlerFunc(han.DeleteFileObject)).Methods("DELETE", "OPTIONS")
230+
apiRouter.Handle("/objects/{objectID}", http.HandlerFunc(han.DeleteFileObject)).Methods("DELETE", "OPTIONS")
231+
// Download object
232+
apiRouter.Handle("/objects/{objectID}/download/", http.HandlerFunc(han.DownloadFileObject)).Methods("GET", "OPTIONS", "HEAD")
233+
apiRouter.Handle("/objects/{objectID}/download", http.HandlerFunc(han.DownloadFileObject)).Methods("GET", "OPTIONS", "HEAD")
234+
// Get object
235+
apiRouter.Handle("/objects/{objectID}/", http.HandlerFunc(han.GetFileObject)).Methods("GET", "OPTIONS")
236+
apiRouter.Handle("/objects/{objectID}", http.HandlerFunc(han.GetFileObject)).Methods("GET", "OPTIONS")
237+
// Update object
238+
apiRouter.Handle("/objects/{objectID}/", http.HandlerFunc(han.UpdateFileObject)).Methods("PUT", "OPTIONS")
239+
apiRouter.Handle("/objects/{objectID}", http.HandlerFunc(han.UpdateFileObject)).Methods("PUT", "OPTIONS")
240+
219241
//////////
220242
// Jobs //
221243
//////////

0 commit comments

Comments
 (0)