feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
This commit is contained in:
1 parent
e33abc948c
commit
a29a8b18ad
49 files changed
+3604
-353
No files matched your search
@@ -0,0 +1,148 @@
|
||||
// Package management authorizes server administration and bridges fixed operations
|
||||
// to the host worker. The API never receives a Docker socket or executes a shell.
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Bridge interface {
|
||||
Call(context.Context, string, string, any, any) error
|
||||
}
|
||||
|
||||
type Mailbox struct{ Dir string }
|
||||
|
||||
func (m Mailbox) Call(ctx context.Context, actor, operation string, body, out any) error {
|
||||
heartbeat, err := os.ReadFile(filepath.Join(m.Dir, "heartbeat"))
|
||||
if err != nil {
|
||||
return apperr.New(503, "management.unavailable", "host agent is not installed or running")
|
||||
}
|
||||
at, err := strconv.ParseFloat(strings.TrimSpace(string(heartbeat)), 64)
|
||||
if err != nil || time.Since(time.Unix(int64(at), 0)) > 60*time.Second {
|
||||
return apperr.New(503, "management.unavailable", "host agent heartbeat expired")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
request := filepath.Join(m.Dir, id+".request.json")
|
||||
response := filepath.Join(m.Dir, id+".response.json")
|
||||
raw, err := json.Marshal(map[string]any{"operation": operation, "actor": actor, "body": body, "created_at": time.Now().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(m.Dir, ".request-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err = tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(tmp.Name(), request); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(response)
|
||||
defer os.Remove(request)
|
||||
ctx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return apperr.New(504, "management.timeout", "host agent did not respond; check status before retrying")
|
||||
case <-ticker.C:
|
||||
raw, err := os.ReadFile(response)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Code string `json:"code"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != "" {
|
||||
status := result.Status
|
||||
if status < 400 || status > 599 {
|
||||
status = 502
|
||||
}
|
||||
return apperr.New(status, result.Code, "host management operation failed")
|
||||
}
|
||||
return json.Unmarshal(result.Data, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Handler struct{ bridge Bridge }
|
||||
|
||||
func NewHandler(bridge Bridge) *Handler { return &Handler{bridge: bridge} }
|
||||
|
||||
func (h *Handler) call(ctx context.Context, admin bool, operation string, body, out any) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized(auth.CodeUnauthorized, "authentication required")
|
||||
}
|
||||
if (admin && p.Role != auth.RoleAdmin) || (!admin && !p.Allowed(auth.PermManageUpdates)) {
|
||||
return apperr.Forbidden(auth.CodeForbidden, "server management permission required")
|
||||
}
|
||||
return h.bridge.Call(ctx, p.UserID.String(), operation, body, out)
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) {
|
||||
var out gen.ServerUpdates
|
||||
err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out)
|
||||
out.CurrentVersion = buildinfo.Version
|
||||
return gen.GetServerUpdates200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServerUpdateSourcesRequestObject) (gen.SaveServerUpdateSourcesResponseObject, error) {
|
||||
var out gen.ServerSources
|
||||
err := h.call(ctx, false, "sources.save", req.Body, &out)
|
||||
return gen.SaveServerUpdateSources200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, false, "updates.start", req.Body, &out)
|
||||
return gen.StartServerUpdate202JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerConfig(ctx context.Context, _ gen.GetServerConfigRequestObject) (gen.GetServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.get", nil, &out)
|
||||
return gen.GetServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerConfig(ctx context.Context, req gen.SaveServerConfigRequestObject) (gen.SaveServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.save", req.Body, &out)
|
||||
return gen.SaveServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) ApplyServerConfig(ctx context.Context, _ gen.ApplyServerConfigRequestObject) (gen.ApplyServerConfigResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, true, "config.apply", nil, &out)
|
||||
return gen.ApplyServerConfig202JSONResponse(out), err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package management_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/server"
|
||||
)
|
||||
|
||||
type bridge struct{ calls int }
|
||||
|
||||
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error {
|
||||
b.calls++
|
||||
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
|
||||
}
|
||||
|
||||
func TestServerManagementAuthorization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
principal auth.Principal
|
||||
updates, config int
|
||||
}{
|
||||
{"user", auth.Principal{Role: auth.RoleUser}, 403, 403},
|
||||
{"moderator", auth.Principal{Role: auth.RoleModerator}, 403, 403},
|
||||
{"delegate", auth.Principal{Role: auth.RoleUser, ManagesUpdates: true}, 200, 403},
|
||||
{"admin", auth.Principal{Role: auth.RoleAdmin}, 200, 200},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := &bridge{}
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
|
||||
for _, op := range []struct {
|
||||
method, path, body string
|
||||
want int
|
||||
}{
|
||||
{"GET", "updates", "", tc.updates},
|
||||
{"POST", "updates", `{"source_id":"origin","channel":"main"}`, tc.updates},
|
||||
{"PUT", "update-sources", `{"sources":[]}`, tc.updates},
|
||||
{"GET", "config", "", tc.config},
|
||||
{"PATCH", "config", `{"revision":"r","values":{}}`, tc.config},
|
||||
{"POST", "config/apply", "", tc.config},
|
||||
} {
|
||||
request := httptest.NewRequest(op.method, "/api/v1/admin/server/"+op.path, strings.NewReader(op.body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(auth.WithPrincipal(request.Context(), tc.principal))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, request)
|
||||
want := op.want
|
||||
if op.method == "POST" && want == 200 {
|
||||
want = 202
|
||||
}
|
||||
require.Equal(t, want, rec.Code, rec.Body.String())
|
||||
}
|
||||
if tc.updates == 403 && tc.config == 403 {
|
||||
require.Zero(t, b.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("anonymous", func(t *testing.T) {
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(&bridge{})})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/server/config", nil))
|
||||
require.Equal(t, 401, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailboxRoundTripAndUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := management.Mailbox{Dir: dir}
|
||||
var out map[string]any
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("1"), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte(time.Now().Format("invalid")), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
raw, _ := json.Marshal(time.Now().Unix())
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), raw, 0600))
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- m.Call(context.Background(), "actor", "config.get", nil, &out) }()
|
||||
var requests []string
|
||||
require.Eventually(t, func() bool {
|
||||
requests, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(requests) == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
request, err := os.ReadFile(requests[0])
|
||||
require.NoError(t, err)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(request, &payload))
|
||||
require.Equal(t, "config.get", payload["operation"])
|
||||
require.Equal(t, "actor", payload["actor"])
|
||||
response := strings.Replace(requests[0], ".request.json", ".response.json", 1)
|
||||
require.NoError(t, os.WriteFile(response, []byte(`{"data":{"revision":"ok"}}`), 0600))
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, "ok", out["revision"])
|
||||
remaining, _ := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
require.Empty(t, remaining)
|
||||
}
|
||||
Reference in new issue
Block a user