Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
771d43d
refactor(network): split app service by resource
JSYoo5B Jun 22, 2026
d7dda14
refactor(compute): split app service by resource
JSYoo5B Jun 22, 2026
2ff668f
refactor(volume): split app service by resource
JSYoo5B Jun 22, 2026
7d49463
feat(runtime): add injectable clock for compute
JSYoo5B Jun 22, 2026
9d17bd8
feat(runtime): add injectable clock for images
JSYoo5B Jun 22, 2026
457f3a7
feat(runtime): add injectable clock for volumes
JSYoo5B Jun 22, 2026
9caed46
feat(runtime): add injectable id generator for compute
JSYoo5B Jun 22, 2026
eccf0ad
feat(runtime): add injectable id generator for images
JSYoo5B Jun 22, 2026
a43fb37
feat(runtime): add injectable id generator for volumes
JSYoo5B Jun 22, 2026
6a8421d
feat(runtime): add injectable id generator for network
JSYoo5B Jun 22, 2026
f83db37
feat(compute): activate servers on read
JSYoo5B Jun 22, 2026
d44a9a9
feat(volume): make volumes available on read
JSYoo5B Jun 22, 2026
6c80f49
feat(runtime): add app service reset support
JSYoo5B Jun 22, 2026
5d12dcd
feat(admin): add reset endpoint
JSYoo5B Jun 22, 2026
bb2bd60
feat(runtime): add request log service
JSYoo5B Jun 22, 2026
32189d4
feat(admin): expose request recording
JSYoo5B Jun 22, 2026
a10ab0e
refactor(api): split router middleware
JSYoo5B Jun 22, 2026
6042343
refactor(image): introduce repository boundary
JSYoo5B Jul 2, 2026
63b73fe
refactor(network): introduce network repository boundary
JSYoo5B Jul 2, 2026
18389b9
refactor(network): introduce subnet repository boundary
JSYoo5B Jul 2, 2026
91f5c87
refactor(network): introduce port repository boundary
JSYoo5B Jul 2, 2026
bc8d870
refactor(compute): introduce server repository boundary
JSYoo5B Jul 2, 2026
6a40197
refactor(volume): introduce volume repository boundary
JSYoo5B Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/api/admin/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package admin

import (
"net/http"

"github.com/JSYoo5B/SandStack/internal/api/respond"
)

func (h Handler) listRequests(w http.ResponseWriter, _ *http.Request) {
respond.JSON(w, http.StatusOK, requestListResponse{
Requests: toRequestDocuments(h.requests.List()),
})
}
28 changes: 28 additions & 0 deletions internal/api/admin/request_dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package admin

import "github.com/JSYoo5B/SandStack/internal/app/requestlog"

type requestListResponse struct {
Requests []requestDocument `json:"requests"`
}

type requestDocument struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
}

func toRequestDocuments(records []requestlog.Record) []requestDocument {
documents := make([]requestDocument, 0, len(records))
for _, record := range records {
documents = append(documents, requestDocument{
ID: record.ID,
Method: record.Method,
Path: record.Path,
Status: record.Status,
})
}

return documents
}
8 changes: 8 additions & 0 deletions internal/api/admin/reset.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package admin

import "net/http"

func (h Handler) resetState(w http.ResponseWriter, _ *http.Request) {
h.reset()
w.WriteHeader(http.StatusNoContent)
}
23 changes: 23 additions & 0 deletions internal/api/admin/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,36 @@ package admin
import (
"net/http"

"github.com/JSYoo5B/SandStack/internal/app/requestlog"
"github.com/go-chi/chi/v5"
)

func NewRouter() http.Handler {
return NewRouterWithReset(func() {})
}

func NewRouterWithReset(reset func()) http.Handler {
return NewRouterWithState(reset, requestlog.NewService())
}

func NewRouterWithState(
reset func(),
requests *requestlog.Service,
) http.Handler {
handler := Handler{
reset: reset,
requests: requests,
}
router := chi.NewRouter()
router.Get("/health", status)
router.Get("/ready", status)
router.Post("/reset", handler.resetState)
router.Get("/requests", handler.listRequests)

return router
}

type Handler struct {
reset func()
requests *requestlog.Service
}
16 changes: 15 additions & 1 deletion internal/api/compute/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler {
return NewHandler(cfg).Router()
}

func NewRouterWithService(
cfg config.Config,
service *appcompute.Service,
) http.Handler {
return NewHandlerWithService(cfg, service).Router()
}

func NewHandler(cfg config.Config) Handler {
return NewHandlerWithService(cfg, appcompute.NewService())
}

func NewHandlerWithService(
cfg config.Config,
service *appcompute.Service,
) Handler {
return Handler{
config: cfg,
service: appcompute.NewService(),
service: service,
}
}

Expand Down
16 changes: 15 additions & 1 deletion internal/api/image/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler {
return NewHandler(cfg).Router()
}

func NewRouterWithService(
cfg config.Config,
service *appimage.Service,
) http.Handler {
return NewHandlerWithService(cfg, service).Router()
}

func NewHandler(cfg config.Config) Handler {
return NewHandlerWithService(cfg, appimage.NewService())
}

func NewHandlerWithService(
cfg config.Config,
service *appimage.Service,
) Handler {
return Handler{
config: cfg,
service: appimage.NewService(),
service: service,
}
}

Expand Down
50 changes: 50 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package api

import (
"net/http"
"strings"

"github.com/JSYoo5B/SandStack/internal/app/requestlog"
"github.com/JSYoo5B/SandStack/internal/platform/idgen"
)

func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Openstack-Request-Id", "req-"+idgen.RandomHex(16))
next.ServeHTTP(w, r)
})
}

func recordRequests(requests *requestlog.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recorder := &statusRecorder{
ResponseWriter: w,
status: http.StatusOK,
}

next.ServeHTTP(recorder, r)

if strings.HasPrefix(r.URL.Path, "/_sandstack") {
return
}

requests.Add(requestlog.Record{
ID: w.Header().Get("X-Openstack-Request-Id"),
Method: r.Method,
Path: r.URL.Path,
Status: recorder.status,
})
})
}
}

type statusRecorder struct {
http.ResponseWriter
status int
}

func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
16 changes: 15 additions & 1 deletion internal/api/network/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler {
return NewHandler(cfg).Router()
}

func NewRouterWithService(
cfg config.Config,
service *appnetwork.Service,
) http.Handler {
return NewHandlerWithService(cfg, service).Router()
}

func NewHandler(cfg config.Config) Handler {
return NewHandlerWithService(cfg, appnetwork.NewService())
}

func NewHandlerWithService(
cfg config.Config,
service *appnetwork.Service,
) Handler {
return Handler{
config: cfg,
service: appnetwork.NewService(),
service: service,
}
}

Expand Down
37 changes: 23 additions & 14 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,43 @@ import (
"github.com/JSYoo5B/SandStack/internal/api/network"
"github.com/JSYoo5B/SandStack/internal/api/placement"
"github.com/JSYoo5B/SandStack/internal/api/volume"
appcompute "github.com/JSYoo5B/SandStack/internal/app/compute"
appimage "github.com/JSYoo5B/SandStack/internal/app/image"
appnetwork "github.com/JSYoo5B/SandStack/internal/app/network"
"github.com/JSYoo5B/SandStack/internal/app/requestlog"
appvolume "github.com/JSYoo5B/SandStack/internal/app/volume"
"github.com/JSYoo5B/SandStack/internal/platform/config"
"github.com/JSYoo5B/SandStack/internal/platform/idgen"
"github.com/go-chi/chi/v5"
)

func NewRouter(cfg config.Config) http.Handler {
router := chi.NewRouter()
router.Use(requestID)
requests := requestlog.NewService()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request recording is created at the top router composition level because it is cross-service SandStack behavior. It should not leak into individual OpenStack service handlers.

router.Use(recordRequests(requests))
identityHandler := identity.NewHandler(cfg)

router.Mount("/_sandstack", admin.NewRouter())
computeService := appcompute.NewService()
imageService := appimage.NewService()
networkService := appnetwork.NewService()
volumeService := appvolume.NewService()

router.Mount("/_sandstack", admin.NewRouterWithState(func() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reset endpoint receives a single composed reset function. This keeps admin routing separate from the concrete services while still making reset behavior cover all in-memory runtime state.

computeService.Reset()
imageService.Reset()
networkService.Reset()
volumeService.Reset()
requests.Reset()
}, requests))

router.Get("/identity", identityHandler.Discovery())
router.Get("/identity/", identityHandler.Discovery())
router.Mount("/identity/v3", identityHandler.Router())

router.Mount("/compute/v2.1", compute.NewRouter(cfg))
router.Mount("/image/v2", image.NewRouter(cfg))
router.Mount("/network/v2.0", network.NewRouter(cfg))
router.Mount("/compute/v2.1", compute.NewRouterWithService(cfg, computeService))
router.Mount("/image/v2", image.NewRouterWithService(cfg, imageService))
router.Mount("/network/v2.0", network.NewRouterWithService(cfg, networkService))
router.Mount("/placement", placement.NewRouter(cfg))
router.Mount("/volume/v3", volume.NewRouter(cfg))
router.Mount("/volume/v3", volume.NewRouterWithService(cfg, volumeService))

return router
}

func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Openstack-Request-Id", "req-"+idgen.RandomHex(16))
next.ServeHTTP(w, r)
})
}
69 changes: 69 additions & 0 deletions internal/api/router_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -9,6 +10,7 @@ import (
"github.com/JSYoo5B/SandStack/internal/platform/config"
"github.com/JSYoo5B/SandStack/internal/testhelper"
"github.com/gophercloud/gophercloud/v2/openstack"
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
"github.com/stretchr/testify/suite"
)

Expand Down Expand Up @@ -49,3 +51,70 @@ func (s *RouterSuite) TestMountedIdentityPasswordAuth() {

s.Assert().NotEmpty(provider.TokenID)
}

func (s *RouterSuite) TestMountedSandstackResetClearsState() {
created, err := images.Create(
s.T().Context(),
testhelper.ServiceClient(s.server.URL+"/image/v2"),
images.CreateOpts{
Name: "ubuntu",
ContainerFormat: "bare",
DiskFormat: "qcow2",
},
).Extract()
s.Require().NoError(err)
s.Require().NotNil(created)

response, err := http.Post(
s.server.URL+"/_sandstack/reset",
"application/json",
nil,
)
s.Require().NoError(err)
defer response.Body.Close()

pages, err := images.List(
testhelper.ServiceClient(s.server.URL+"/image/v2"),
nil,
).AllPages(s.T().Context())
s.Require().NoError(err)

list, err := images.ExtractImages(pages)
s.Require().NoError(err)

s.Assert().Equal(http.StatusNoContent, response.StatusCode)
s.Assert().Empty(list)
}

func (s *RouterSuite) TestMountedSandstackRequestsRecordsOpenStackAPI() {
response, err := http.Get(s.server.URL + "/image/v2/images")
s.Require().NoError(err)
defer response.Body.Close()

requestsResponse, err := http.Get(s.server.URL + "/_sandstack/requests")
s.Require().NoError(err)
defer requestsResponse.Body.Close()

var body requestListResponse
err = json.NewDecoder(requestsResponse.Body).Decode(&body)
s.Require().NoError(err)

s.Assert().Equal(http.StatusOK, response.StatusCode)
s.Assert().Equal(http.StatusOK, requestsResponse.StatusCode)
s.Require().Len(body.Requests, 1)
s.Assert().NotEmpty(body.Requests[0].ID)
s.Assert().Equal(http.MethodGet, body.Requests[0].Method)
s.Assert().Equal("/image/v2/images", body.Requests[0].Path)
s.Assert().Equal(http.StatusOK, body.Requests[0].Status)
}

type requestListResponse struct {
Requests []requestDocument `json:"requests"`
}

type requestDocument struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
}
16 changes: 15 additions & 1 deletion internal/api/volume/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler {
return NewHandler(cfg).Router()
}

func NewRouterWithService(
cfg config.Config,
service *appvolume.Service,
) http.Handler {
return NewHandlerWithService(cfg, service).Router()
}

func NewHandler(cfg config.Config) Handler {
return NewHandlerWithService(cfg, appvolume.NewService())
}

func NewHandlerWithService(
cfg config.Config,
service *appvolume.Service,
) Handler {
return Handler{
config: cfg,
service: appvolume.NewService(),
service: service,
}
}

Expand Down
Loading
Loading