forked from wrenn/wrenn
Add filesystem operations (list, mkdir, remove) across full stack
Plumb ListDir, MakeDir, and RemovePath through all layers:
REST API → host agent RPC → envdclient → envd. These endpoints
enable a web file browser for sandbox filesystem interaction.
New endpoints (all under requireAPIKeyOrJWT):
- POST /v1/sandboxes/{id}/files/list
- POST /v1/sandboxes/{id}/files/mkdir
- POST /v1/sandboxes/{id}/files/remove
This commit is contained in:
236
internal/api/handlers_fs.go
Normal file
236
internal/api/handlers_fs.go
Normal file
@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/db"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/id"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||
)
|
||||
|
||||
type fsHandler struct {
|
||||
db *db.Queries
|
||||
pool *lifecycle.HostClientPool
|
||||
}
|
||||
|
||||
func newFSHandler(db *db.Queries, pool *lifecycle.HostClientPool) *fsHandler {
|
||||
return &fsHandler{db: db, pool: pool}
|
||||
}
|
||||
|
||||
type listDirRequest struct {
|
||||
Path string `json:"path"`
|
||||
Depth uint32 `json:"depth"`
|
||||
}
|
||||
|
||||
type fileEntryResponse struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Mode uint32 `json:"mode"`
|
||||
Permissions string `json:"permissions"`
|
||||
Owner string `json:"owner"`
|
||||
Group string `json:"group"`
|
||||
ModifiedAt int64 `json:"modified_at"`
|
||||
SymlinkTarget *string `json:"symlink_target,omitempty"`
|
||||
}
|
||||
|
||||
type listDirResponse struct {
|
||||
Entries []fileEntryResponse `json:"entries"`
|
||||
}
|
||||
|
||||
type makeDirRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type makeDirResponse struct {
|
||||
Entry fileEntryResponse `json:"entry"`
|
||||
}
|
||||
|
||||
type removeRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ListDir handles POST /v1/sandboxes/{id}/files/list.
|
||||
func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
ctx := r.Context()
|
||||
ac := auth.MustFromContext(ctx)
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
return
|
||||
}
|
||||
if sb.Status != "running" {
|
||||
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||
return
|
||||
}
|
||||
|
||||
var req listDirRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := agent.ListDir(ctx, connect.NewRequest(&pb.ListDirRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Path: req.Path,
|
||||
Depth: req.Depth,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
entries := make([]fileEntryResponse, 0, len(resp.Msg.Entries))
|
||||
for _, e := range resp.Msg.Entries {
|
||||
entries = append(entries, fileEntryFromPB(e))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, listDirResponse{Entries: entries})
|
||||
}
|
||||
|
||||
// MakeDir handles POST /v1/sandboxes/{id}/files/mkdir.
|
||||
func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
ctx := r.Context()
|
||||
ac := auth.MustFromContext(ctx)
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
return
|
||||
}
|
||||
if sb.Status != "running" {
|
||||
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||
return
|
||||
}
|
||||
|
||||
var req makeDirRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := agent.MakeDir(ctx, connect.NewRequest(&pb.MakeDirRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Path: req.Path,
|
||||
}))
|
||||
if err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, makeDirResponse{Entry: fileEntryFromPB(resp.Msg.Entry)})
|
||||
}
|
||||
|
||||
// Remove handles POST /v1/sandboxes/{id}/files/remove.
|
||||
func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
ctx := r.Context()
|
||||
ac := auth.MustFromContext(ctx)
|
||||
|
||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||
return
|
||||
}
|
||||
|
||||
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||
return
|
||||
}
|
||||
if sb.Status != "running" {
|
||||
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||
return
|
||||
}
|
||||
|
||||
var req removeRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := agent.RemovePath(ctx, connect.NewRequest(&pb.RemovePathRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Path: req.Path,
|
||||
})); err != nil {
|
||||
status, code, msg := agentErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func fileEntryFromPB(e *pb.FileEntry) fileEntryResponse {
|
||||
if e == nil {
|
||||
return fileEntryResponse{}
|
||||
}
|
||||
resp := fileEntryResponse{
|
||||
Name: e.Name,
|
||||
Path: e.Path,
|
||||
Type: e.Type,
|
||||
Size: e.Size,
|
||||
Mode: e.Mode,
|
||||
Permissions: e.Permissions,
|
||||
Owner: e.Owner,
|
||||
Group: e.Group,
|
||||
ModifiedAt: e.ModifiedAt,
|
||||
}
|
||||
if e.SymlinkTarget != nil {
|
||||
resp.SymlinkTarget = e.SymlinkTarget
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@ -1037,6 +1037,122 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/sandboxes/{id}/files/list:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: List directory contents
|
||||
operationId: listDir
|
||||
tags: [sandboxes]
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListDirRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Directory listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListDirResponse"
|
||||
"404":
|
||||
description: Sandbox not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Sandbox not running
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/sandboxes/{id}/files/mkdir:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: Create a directory
|
||||
operationId: makeDir
|
||||
tags: [sandboxes]
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MakeDirRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Directory created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MakeDirResponse"
|
||||
"404":
|
||||
description: Sandbox not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Sandbox not running
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/sandboxes/{id}/files/remove:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
||||
post:
|
||||
summary: Remove a file or directory
|
||||
operationId: removePath
|
||||
tags: [sandboxes]
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoveRequest"
|
||||
responses:
|
||||
"204":
|
||||
description: File or directory removed
|
||||
"404":
|
||||
description: Sandbox not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Sandbox not running
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/sandboxes/{id}/exec/stream:
|
||||
parameters:
|
||||
- name: id
|
||||
@ -1988,6 +2104,78 @@ components:
|
||||
type: string
|
||||
description: Absolute file path inside the sandbox
|
||||
|
||||
ListDirRequest:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Directory path inside the sandbox
|
||||
depth:
|
||||
type: integer
|
||||
default: 1
|
||||
description: Recursion depth (0 = non-recursive, 1 = immediate children)
|
||||
|
||||
ListDirResponse:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/FileEntry"
|
||||
|
||||
FileEntry:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [file, directory, symlink]
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
mode:
|
||||
type: integer
|
||||
permissions:
|
||||
type: string
|
||||
description: Human-readable permissions (e.g. "-rwxr-xr-x")
|
||||
owner:
|
||||
type: string
|
||||
group:
|
||||
type: string
|
||||
modified_at:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Unix timestamp (seconds)
|
||||
symlink_target:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
MakeDirRequest:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Directory path to create inside the sandbox
|
||||
|
||||
MakeDirResponse:
|
||||
type: object
|
||||
properties:
|
||||
entry:
|
||||
$ref: "#/components/schemas/FileEntry"
|
||||
|
||||
RemoveRequest:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: Path to remove inside the sandbox
|
||||
|
||||
CreateHostRequest:
|
||||
type: object
|
||||
required: [type]
|
||||
|
||||
@ -60,6 +60,7 @@ func New(
|
||||
execStream := newExecStreamHandler(queries, pool)
|
||||
files := newFilesHandler(queries, pool)
|
||||
filesStream := newFilesStreamHandler(queries, pool)
|
||||
fsH := newFSHandler(queries, pool)
|
||||
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
|
||||
authH := newAuthHandler(queries, pgPool, jwtSecret)
|
||||
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
|
||||
@ -133,6 +134,9 @@ func New(
|
||||
r.Post("/files/read", files.Download)
|
||||
r.Post("/files/stream/write", filesStream.StreamUpload)
|
||||
r.Post("/files/stream/read", filesStream.StreamDownload)
|
||||
r.Post("/files/list", fsH.ListDir)
|
||||
r.Post("/files/mkdir", fsH.MakeDir)
|
||||
r.Post("/files/remove", fsH.Remove)
|
||||
r.Get("/metrics", metricsH.GetMetrics)
|
||||
})
|
||||
})
|
||||
|
||||
@ -282,3 +282,30 @@ func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdp
|
||||
|
||||
return resp.Msg, nil
|
||||
}
|
||||
|
||||
// MakeDir creates a directory inside the sandbox.
|
||||
func (c *Client) MakeDir(ctx context.Context, path string) (*envdpb.MakeDirResponse, error) {
|
||||
req := connect.NewRequest(&envdpb.MakeDirRequest{
|
||||
Path: path,
|
||||
})
|
||||
|
||||
resp, err := c.filesystem.MakeDir(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("make dir: %w", err)
|
||||
}
|
||||
|
||||
return resp.Msg, nil
|
||||
}
|
||||
|
||||
// Remove removes a file or directory inside the sandbox.
|
||||
func (c *Client) Remove(ctx context.Context, path string) error {
|
||||
req := connect.NewRequest(&envdpb.RemoveRequest{
|
||||
Path: path,
|
||||
})
|
||||
|
||||
if _, err := c.filesystem.Remove(ctx, req); err != nil {
|
||||
return fmt.Errorf("remove: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
|
||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||
|
||||
@ -252,6 +253,69 @@ func (s *Server) ReadFile(
|
||||
return connect.NewResponse(&pb.ReadFileResponse{Content: content}), nil
|
||||
}
|
||||
|
||||
func (s *Server) ListDir(
|
||||
ctx context.Context,
|
||||
req *connect.Request[pb.ListDirRequest],
|
||||
) (*connect.Response[pb.ListDirResponse], error) {
|
||||
msg := req.Msg
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
|
||||
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list dir: %w", err))
|
||||
}
|
||||
|
||||
entries := make([]*pb.FileEntry, 0, len(resp.Entries))
|
||||
for _, e := range resp.Entries {
|
||||
entries = append(entries, entryInfoToPB(e))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.ListDirResponse{Entries: entries}), nil
|
||||
}
|
||||
|
||||
func (s *Server) MakeDir(
|
||||
ctx context.Context,
|
||||
req *connect.Request[pb.MakeDirRequest],
|
||||
) (*connect.Response[pb.MakeDirResponse], error) {
|
||||
msg := req.Msg
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
|
||||
resp, err := client.MakeDir(ctx, msg.Path)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("make dir: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.MakeDirResponse{
|
||||
Entry: entryInfoToPB(resp.Entry),
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Server) RemovePath(
|
||||
ctx context.Context,
|
||||
req *connect.Request[pb.RemovePathRequest],
|
||||
) (*connect.Response[pb.RemovePathResponse], error) {
|
||||
msg := req.Msg
|
||||
|
||||
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||
if err != nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
|
||||
if err := client.Remove(ctx, msg.Path); err != nil {
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("remove: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.RemovePathResponse{}), nil
|
||||
}
|
||||
|
||||
func (s *Server) ExecStream(
|
||||
ctx context.Context,
|
||||
req *connect.Request[pb.ExecStreamRequest],
|
||||
@ -545,3 +609,43 @@ func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entryInfoToPB maps an envd EntryInfo to a hostagent FileEntry.
|
||||
func entryInfoToPB(e *envdpb.EntryInfo) *pb.FileEntry {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fileType string
|
||||
switch e.Type {
|
||||
case envdpb.FileType_FILE_TYPE_FILE:
|
||||
fileType = "file"
|
||||
case envdpb.FileType_FILE_TYPE_DIRECTORY:
|
||||
fileType = "directory"
|
||||
case envdpb.FileType_FILE_TYPE_SYMLINK:
|
||||
fileType = "symlink"
|
||||
default:
|
||||
fileType = "unknown"
|
||||
}
|
||||
|
||||
entry := &pb.FileEntry{
|
||||
Name: e.Name,
|
||||
Path: e.Path,
|
||||
Type: fileType,
|
||||
Size: e.Size,
|
||||
Mode: e.Mode,
|
||||
Permissions: e.Permissions,
|
||||
Owner: e.Owner,
|
||||
Group: e.Group,
|
||||
}
|
||||
|
||||
if e.ModifiedTime != nil {
|
||||
entry.ModifiedAt = e.ModifiedTime.GetSeconds()
|
||||
}
|
||||
|
||||
if e.SymlinkTarget != nil {
|
||||
entry.SymlinkTarget = e.SymlinkTarget
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user