Skip to content

Commit b2a141a

Browse files
committed
api: add dedicated /instances/{id}/stat endpoint
Add a separate HTTP endpoint for querying filesystem path info in guests. This replaces the overloaded 'direction: stat' option in the cp WebSocket endpoint with a cleaner REST API. - Add PathInfo schema with exists, is_dir, is_file, is_symlink, etc. - Add GET /instances/{id}/stat endpoint with path and follow_links params - Add stat method to stainless.yaml for SDK generation The new endpoint: - Uses simple HTTP GET instead of WebSocket overhead - Enables autogenerated SDK methods (client.Instances.Stat()) - Mirrors the guest agent's separate StatPath RPC - Is self-documenting via OpenAPI spec
1 parent 1b85850 commit b2a141a

File tree

5 files changed

+621
-146
lines changed

5 files changed

+621
-146
lines changed

cmd/api/api/cp.go

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func (e *cpErrorSent) Unwrap() error { return e.err }
2727

2828
// CpRequest represents the JSON body for copy requests
2929
type CpRequest struct {
30-
// Direction: "to" copies from client to guest, "from" copies from guest to client, "stat" queries path info
30+
// Direction: "to" copies from client to guest, "from" copies from guest to client
3131
Direction string `json:"direction"`
3232
// Path in the guest filesystem
3333
GuestPath string `json:"guest_path"`
@@ -45,19 +45,6 @@ type CpRequest struct {
4545
Gid uint32 `json:"gid,omitempty"`
4646
}
4747

48-
// CpStatResponse contains information about a path in the guest
49-
type CpStatResponse struct {
50-
Type string `json:"type"` // "stat"
51-
Exists bool `json:"exists"`
52-
IsDir bool `json:"is_dir"`
53-
IsFile bool `json:"is_file"`
54-
IsSymlink bool `json:"is_symlink,omitempty"`
55-
LinkTarget string `json:"link_target,omitempty"`
56-
Mode uint32 `json:"mode"`
57-
Size int64 `json:"size"`
58-
Error string `json:"error,omitempty"`
59-
}
60-
6148
// CpFileHeader is sent before file data in WebSocket protocol
6249
type CpFileHeader struct {
6350
Type string `json:"type"` // "header"
@@ -165,10 +152,8 @@ func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) {
165152
cpErr = s.handleCopyTo(ctx, ws, inst, cpReq)
166153
case "from":
167154
cpErr = s.handleCopyFrom(ctx, ws, inst, cpReq)
168-
case "stat":
169-
cpErr = s.handleStat(ctx, ws, inst, cpReq)
170155
default:
171-
cpErr = fmt.Errorf("invalid direction: %s (must be 'to', 'from', or 'stat')", cpReq.Direction)
156+
cpErr = fmt.Errorf("invalid direction: %s (must be 'to' or 'from')", cpReq.Direction)
172157
}
173158

174159
duration := time.Since(startTime)
@@ -383,34 +368,3 @@ func (s *ApiService) handleCopyFrom(ctx context.Context, ws *websocket.Conn, ins
383368
return nil
384369
}
385370

386-
// handleStat returns information about a path in the guest
387-
func (s *ApiService) handleStat(ctx context.Context, ws *websocket.Conn, inst *instances.Instance, req CpRequest) error {
388-
grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket)
389-
if err != nil {
390-
return fmt.Errorf("get grpc connection: %w", err)
391-
}
392-
393-
client := guest.NewGuestServiceClient(grpcConn)
394-
resp, err := client.StatPath(ctx, &guest.StatPathRequest{
395-
Path: req.GuestPath,
396-
FollowLinks: req.FollowLinks,
397-
})
398-
if err != nil {
399-
return fmt.Errorf("stat path: %w", err)
400-
}
401-
402-
statResp := CpStatResponse{
403-
Type: "stat",
404-
Exists: resp.Exists,
405-
IsDir: resp.IsDir,
406-
IsFile: resp.IsFile,
407-
IsSymlink: resp.IsSymlink,
408-
LinkTarget: resp.LinkTarget,
409-
Mode: resp.Mode,
410-
Size: resp.Size,
411-
Error: resp.Error,
412-
}
413-
respJSON, _ := json.Marshal(statResp)
414-
return ws.WriteMessage(websocket.TextMessage, respJSON)
415-
}
416-

cmd/api/api/instances.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99

1010
"github.com/c2h5oh/datasize"
11+
"github.com/onkernel/hypeman/lib/guest"
1112
"github.com/onkernel/hypeman/lib/instances"
1213
"github.com/onkernel/hypeman/lib/logger"
1314
mw "github.com/onkernel/hypeman/lib/middleware"
@@ -430,6 +431,73 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan
430431
return logsStreamResponse{logChan: logChan}, nil
431432
}
432433

434+
// StatInstancePath returns information about a path in the guest filesystem
435+
// The id parameter can be an instance ID, name, or ID prefix
436+
// Note: Resolution is handled by ResolveResource middleware
437+
func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInstancePathRequestObject) (oapi.StatInstancePathResponseObject, error) {
438+
log := logger.FromContext(ctx)
439+
440+
inst := mw.GetResolvedInstance[instances.Instance](ctx)
441+
if inst == nil {
442+
return oapi.StatInstancePath500JSONResponse{
443+
Code: "internal_error",
444+
Message: "resource not resolved",
445+
}, nil
446+
}
447+
448+
if inst.State != instances.StateRunning {
449+
return oapi.StatInstancePath409JSONResponse{
450+
Code: "invalid_state",
451+
Message: fmt.Sprintf("instance must be running (current state: %s)", inst.State),
452+
}, nil
453+
}
454+
455+
// Connect to guest agent
456+
grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket)
457+
if err != nil {
458+
log.ErrorContext(ctx, "failed to get grpc connection", "error", err)
459+
return oapi.StatInstancePath500JSONResponse{
460+
Code: "internal_error",
461+
Message: "failed to connect to guest agent",
462+
}, nil
463+
}
464+
465+
client := guest.NewGuestServiceClient(grpcConn)
466+
followLinks := false
467+
if request.Params.FollowLinks != nil {
468+
followLinks = *request.Params.FollowLinks
469+
}
470+
471+
resp, err := client.StatPath(ctx, &guest.StatPathRequest{
472+
Path: request.Params.Path,
473+
FollowLinks: followLinks,
474+
})
475+
if err != nil {
476+
log.ErrorContext(ctx, "stat path failed", "error", err, "path", request.Params.Path)
477+
return oapi.StatInstancePath500JSONResponse{
478+
Code: "internal_error",
479+
Message: "failed to stat path in guest",
480+
}, nil
481+
}
482+
483+
// Convert types from protobuf to OAPI
484+
mode := int(resp.Mode)
485+
response := oapi.StatInstancePath200JSONResponse{
486+
Exists: resp.Exists,
487+
IsDir: &resp.IsDir,
488+
IsFile: &resp.IsFile,
489+
IsSymlink: &resp.IsSymlink,
490+
LinkTarget: &resp.LinkTarget,
491+
Mode: &mode,
492+
Size: &resp.Size,
493+
}
494+
// Include error message if stat failed (e.g., permission denied)
495+
if resp.Error != "" {
496+
response.Error = &resp.Error
497+
}
498+
return response, nil
499+
}
500+
433501
// AttachVolume attaches a volume to an instance (not yet implemented)
434502
func (s *ApiService) AttachVolume(ctx context.Context, request oapi.AttachVolumeRequestObject) (oapi.AttachVolumeResponseObject, error) {
435503
return oapi.AttachVolume500JSONResponse{

0 commit comments

Comments
 (0)