diff --git a/.gitignore b/.gitignore index 0667eb9..1f1d48d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ config.yaml # Editor/IDE .idea/ .vscode/ + +# LLM friends +CLAUDE.md +AGENTS.md diff --git a/README.md b/README.md index 0c63b8d..991e074 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bedrock API -![Coverage](https://img.shields.io/badge/coverage-37.6%25-red) +![Coverage](https://img.shields.io/badge/coverage-38.6%25-red) **Bedrock API** is an HTTP service that coordinates **Bedrock tracing workloads** through REST APIs and internal event-driven communication. @@ -212,6 +212,40 @@ Creates: * new session and assigns a DockerD +Request body (application/json): + +```json +{ + "image": "", + "command": "", + "ttl": "" +} +``` + +Fields: + +* image (string) - Docker image to run (e.g. "nginx:stable"). +* command (string) - space-separated command string that will be split on spaces and passed to the container as an argv list. +* ttl (integer) - session time-to-live in seconds; DockerD will stop the session after this duration if it hasn't finished. + +Example: start a short-lived nginx session + +```json +{ + "image": "nginx:stable", + "command": "nginx -g 'daemon off;'", + "ttl": "24h" +} +``` + +Example curl (create session): + +```sh +curl -X POST http://localhost:8080/api/sessions \ + -H "Content-Type: application/json" \ + -d '{"image":"nginx:stable","command":"nginx -g \'daemon off;\'","ttl":60}' +``` + ### Stop Session ```http @@ -222,6 +256,31 @@ Creates: * patch event for DockerD +Request body (application/json): + +```json +{ "status": "" } +``` + +Notes: + +* Only the `status` field is read by the API for updates. Valid status values are: `pending`, `running`, `stopped`, `finished`, `failed`. +* The server enforces a state-machine when applying status changes; invalid transitions will return HTTP 400. + +Example: stop a running session + +```json +{ "status": "stopped" } +``` + +Example curl (stop session): + +```sh +curl -X PUT http://localhost:8080/api/sessions/ \ + -H "Content-Type: application/json" \ + -d '{"status":"stopped"}' +``` + ### List Sessions ```http @@ -230,6 +289,30 @@ GET /api/sessions Returns all sessions from KV storage. +Request body: none + +Response (application/json): array of Session objects. Each Session has the shape: + +```json +{ + "id": "", + "dockerd_id": "", + "created_at": "", + "status": "", + "spec": { + "image": "", + "command": "", + "ttl": + } +} +``` + +Example curl (list sessions): + +```sh +curl -X GET http://localhost:8080/api/sessions +``` + ### Get Session Logs ```http @@ -238,6 +321,14 @@ GET /api/sessions/:id/logs Returns session tracing logs. +Note: endpoint is currently not implemented in the codebase (returns HTTP 501). + +Example curl (get session logs): + +```sh +curl -X GET http://localhost:8080/api/sessions//logs +``` + ### Store Session Logs ```http @@ -246,6 +337,15 @@ POST /api/sessions/:id/logs Uploads session tracing logs. +Intended usage: upload tracing artifacts for the given session. The current handlers are not implemented in the codebase (returns HTTP 501). When implemented, this endpoint is expected to accept file uploads (multipart/form-data) containing the logs/artifacts for the session. + +Example curl (upload session logs): + +```sh +curl -X POST http://localhost:8080/api/sessions//logs \ + -F "file=@/path/to/log.tar.gz" +``` + ## Requirements * Docker diff --git a/internal/ports/http/handlers.go b/internal/ports/http/handlers.go index d2b31b9..a8da3d5 100644 --- a/internal/ports/http/handlers.go +++ b/internal/ports/http/handlers.go @@ -1,10 +1,14 @@ package http import ( + "encoding/json" "net/http" + "time" + "github.com/amirhnajafiz/bedrock-api/pkg/enums" "github.com/amirhnajafiz/bedrock-api/pkg/models" + "github.com/google/uuid" "github.com/labstack/echo/v5" "go.uber.org/zap" ) @@ -23,17 +27,92 @@ func (h HTTPServer) health(c *echo.Context) error { // createSession creates a new session based on the request payload and returns the session ID. func (h HTTPServer) createSession(c *echo.Context) error { - return c.String(http.StatusNotImplemented, "Not implemented") + var raw map[string]json.RawMessage + if err := json.NewDecoder(c.Request().Body).Decode(&raw); err != nil { + return c.String(http.StatusBadRequest, "invalid request body") + } + + var spec models.Spec + if err := json.Unmarshal(raw["image"], &spec.Image); err != nil { + return c.String(http.StatusBadRequest, "invalid image") + } + if err := json.Unmarshal(raw["command"], &spec.Command); err != nil { + return c.String(http.StatusBadRequest, "invalid command") + } + + var ttlStr string + if err := json.Unmarshal(raw["ttl"], &ttlStr); err != nil { + return c.String(http.StatusBadRequest, "invalid ttl") + } + ttl, err := time.ParseDuration(ttlStr) + if err != nil { + return c.String(http.StatusBadRequest, "invalid ttl format (e.g. 1s, 1m, 1h)") + } + spec.TTL = ttl + + // assign DockerD to this session + dockerd, err := h.scheduler.Pick() + if err != nil { + return c.String(http.StatusServiceUnavailable, "no docker daemons available") + } + + // create and save session to KV store + session := models.Session{ + Id: uuid.New().String(), + DockerDId: dockerd, + CreatedAt: time.Now(), + Status: enums.SessionStatusPending, + Spec: spec, + } + h.sessionStore.SaveSession(&session) + + return c.JSON(http.StatusCreated, session) } // updateSession updates an existing session with the specified ID based on the request payload. func (h HTTPServer) updateSession(c *echo.Context) error { - return c.String(http.StatusNotImplemented, "Not implemented") + // read path params + id := c.Param("id") + + // payload only contains the new desired state + var payload struct { + Status enums.SessionStatus `json:"status"` + } + if err := c.Bind(&payload); err != nil { + return c.String(http.StatusBadRequest, "invalid request body") + } + + // fetch existing session by id (store will find the owning dockerd) + session, err := h.sessionStore.GetSessionById(id) + if err != nil { + h.Logr.Warn("failed to get session", zap.Error(err), zap.String("session id", id)) + return c.String(http.StatusNotFound, "session not found") + } + + // apply state machine transition + newState := h.stateMachine.Transition(session.Status, payload.Status) + if newState == session.Status { + // transition not allowed or no-op + return c.String(http.StatusBadRequest, "invalid state transition") + } + + session.Status = newState + + if err := h.sessionStore.SaveSession(session); err != nil { + h.Logr.Warn("failed to save session", zap.Error(err), zap.String("session id", id), zap.String("dockerd id", session.DockerDId)) + return c.String(http.StatusInternalServerError, "failed to save session") + } + + return c.JSON(http.StatusOK, session) } // getSessions retrieves a list of all sessions and returns them in the response. func (h HTTPServer) getSessions(c *echo.Context) error { - return c.String(http.StatusNotImplemented, "Not implemented") + sessions, err := h.sessionStore.ListSessions() + if err != nil { + return c.String(http.StatusInternalServerError, "failed to list sessions") + } + return c.JSON(http.StatusFound, sessions) } // getSessionLogs retrieves the logs for a specific session based on the session ID provided in the request parameters. diff --git a/internal/ports/http/http.go b/internal/ports/http/http.go index 802a7fb..7ae426f 100644 --- a/internal/ports/http/http.go +++ b/internal/ports/http/http.go @@ -6,6 +6,7 @@ import ( "github.com/amirhnajafiz/bedrock-api/internal/components/sessions" zmqclient "github.com/amirhnajafiz/bedrock-api/internal/components/zmq_client" "github.com/amirhnajafiz/bedrock-api/internal/scheduler" + "github.com/amirhnajafiz/bedrock-api/internal/state_machine" "github.com/amirhnajafiz/bedrock-api/internal/storage" "github.com/labstack/echo/v5" @@ -23,6 +24,7 @@ type HTTPServer struct { scheduler scheduler.Scheduler sessionStore sessions.SessionStore zclient *zmqclient.ZMQClient + stateMachine *statemachine.StateMachine } // NewHTTPServer creates and returns a new instance of HTTPServer. @@ -32,6 +34,7 @@ func (h HTTPServer) Build(address, socketAddress string) *HTTPServer { h.scheduler = scheduler.NewRoundRobin() h.sessionStore = sessions.NewSessionStore(storage.NewGoCache()) h.zclient = zmqclient.NewZMQClient(socketAddress) + h.stateMachine = statemachine.NewStateMachine() return &h }