Feat: add POSIX-lite bootstrap foundation
This commit is contained in:
@@ -4,8 +4,11 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -41,7 +44,8 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
db *database.DB
|
||||
posixRoot string
|
||||
}
|
||||
|
||||
type SaveInstanceInput struct {
|
||||
@@ -172,8 +176,8 @@ type namedRecord struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func NewService(db *database.DB) *Service {
|
||||
return &Service{db: db}
|
||||
func NewService(db *database.DB, posixRoot string) *Service {
|
||||
return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
|
||||
}
|
||||
|
||||
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) {
|
||||
@@ -405,6 +409,10 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
|
||||
return StructureRecord{}, err
|
||||
}
|
||||
|
||||
if err := service.ensureBootstrapPOSIXSkeleton(installation, admin, organization, department, team, project); err != nil {
|
||||
return StructureRecord{}, err
|
||||
}
|
||||
|
||||
return StructureRecord{
|
||||
Installation: installation,
|
||||
Organization: organization,
|
||||
@@ -901,3 +909,173 @@ func personalHomeTitle(displayName string) string {
|
||||
|
||||
return fmt.Sprintf("%s's Home", trimmedDisplayName)
|
||||
}
|
||||
|
||||
func (service *Service) ensureBootstrapPOSIXSkeleton(
|
||||
installation InstallationRecord,
|
||||
admin AdminSummary,
|
||||
organization namedRecord,
|
||||
department namedRecord,
|
||||
team namedRecord,
|
||||
project namedRecord,
|
||||
) error {
|
||||
rootPath := strings.TrimSpace(service.posixRoot)
|
||||
if rootPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(rootPath, 0o755); err != nil {
|
||||
return fmt.Errorf("create POSIX root: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(rootPath, "settings.json"), map[string]any{
|
||||
"installation": map[string]any{
|
||||
"id": installation.ID,
|
||||
"name": installation.Name,
|
||||
"mode": installation.Mode,
|
||||
"access": installation.Access,
|
||||
"protocol": installation.Protocol,
|
||||
"host": installation.Host,
|
||||
"isBootstrapped": installation.IsBootstrapped,
|
||||
},
|
||||
"organization": map[string]any{
|
||||
"id": organization.ID,
|
||||
"name": organization.Name,
|
||||
"slug": organization.Slug,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write tenant settings.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(rootPath, "layout.json"), map[string]any{
|
||||
"version": 1,
|
||||
"type": "tenant-layout",
|
||||
"home": map[string]any{
|
||||
"defaultProjectSlug": project.Slug,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write tenant layout.json: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "packs"), 0o755); err != nil {
|
||||
return fmt.Errorf("create catalog packs root: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(rootPath, "catalog", "standalone"), 0o755); err != nil {
|
||||
return fmt.Errorf("create catalog standalone root: %w", err)
|
||||
}
|
||||
|
||||
departmentPath := filepath.Join(rootPath, "departments", slugDir("department", department.Slug))
|
||||
teamPath := filepath.Join(departmentPath, "teams", slugDir("team", team.Slug))
|
||||
projectPath := filepath.Join(rootPath, "projects", slugDir("project", project.Slug))
|
||||
usersPath := filepath.Join(rootPath, "users")
|
||||
|
||||
for _, dirPath := range []string{
|
||||
departmentPath,
|
||||
teamPath,
|
||||
projectPath,
|
||||
filepath.Join(projectPath, "tree"),
|
||||
filepath.Join(usersPath, "personals"),
|
||||
} {
|
||||
if err := os.MkdirAll(dirPath, 0o755); err != nil {
|
||||
return fmt.Errorf("create POSIX directory %s: %w", dirPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(departmentPath, "settings.json"), map[string]any{
|
||||
"id": department.ID,
|
||||
"name": department.Name,
|
||||
"slug": department.Slug,
|
||||
"type": "department",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write department settings.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(departmentPath, "users.json"), map[string]any{
|
||||
"owners": []map[string]string{{
|
||||
"id": admin.ID,
|
||||
"email": admin.Email,
|
||||
"displayName": admin.DisplayName,
|
||||
}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write department users.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(teamPath, "settings.json"), map[string]any{
|
||||
"id": team.ID,
|
||||
"name": team.Name,
|
||||
"slug": team.Slug,
|
||||
"type": "team",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write team settings.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(teamPath, "users.json"), map[string]any{
|
||||
"owners": []map[string]string{{
|
||||
"id": admin.ID,
|
||||
"email": admin.Email,
|
||||
"displayName": admin.DisplayName,
|
||||
}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write team users.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(projectPath, "settings.json"), map[string]any{
|
||||
"id": project.ID,
|
||||
"name": project.Name,
|
||||
"slug": project.Slug,
|
||||
"type": "project",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write project settings.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(projectPath, "home.json"), map[string]any{
|
||||
"type": "project-home",
|
||||
"project": project.Slug,
|
||||
"widgets": []any{},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write project home.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(usersPath, "settings.json"), map[string]any{
|
||||
"primaryAdminId": admin.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write users settings.json: %w", err)
|
||||
}
|
||||
|
||||
if err := writeJSONFile(filepath.Join(usersPath, "data.json"), map[string]any{
|
||||
"users": []map[string]string{{
|
||||
"id": admin.ID,
|
||||
"email": admin.Email,
|
||||
"displayName": admin.DisplayName,
|
||||
}},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write users data.json: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func slugDir(prefix, slug string) string {
|
||||
trimmedSlug := strings.TrimSpace(slug)
|
||||
if trimmedSlug == "" {
|
||||
return prefix
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s-%s", prefix, trimmedSlug)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, payload any) error {
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data = append(data, '\n')
|
||||
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
113
Backend/internal/bootstrap/service_test.go
Normal file
113
Backend/internal/bootstrap/service_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot(t *testing.T) {
|
||||
rootPath := filepath.Join(t.TempDir(), "POSIX")
|
||||
t.Setenv("POSIX_ROOT", rootPath)
|
||||
|
||||
if _, err := os.Stat(rootPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected isolated POSIX root to start absent, got err=%v", err)
|
||||
}
|
||||
|
||||
service := NewService(nil, os.Getenv("POSIX_ROOT"))
|
||||
|
||||
err := service.ensureBootstrapPOSIXSkeleton(
|
||||
InstallationRecord{
|
||||
ID: "installation-1",
|
||||
Name: "MangoPig",
|
||||
Mode: "personal",
|
||||
Access: "local",
|
||||
Protocol: "http",
|
||||
Host: "localhost",
|
||||
IsBootstrapped: true,
|
||||
},
|
||||
AdminSummary{
|
||||
ID: "admin-1",
|
||||
Email: "ronald@example.com",
|
||||
DisplayName: "Ronald",
|
||||
},
|
||||
namedRecord{ID: "org-1", Name: "Primary Organization", Slug: "primary-organization"},
|
||||
namedRecord{ID: "dept-1", Name: "Primary Department", Slug: "primary-department"},
|
||||
namedRecord{ID: "team-1", Name: "Primary Team", Slug: "primary-team"},
|
||||
namedRecord{ID: "project-1", Name: "Primary Project", Slug: "primary-project"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure bootstrap POSIX skeleton: %v", err)
|
||||
}
|
||||
|
||||
requiredPaths := []string{
|
||||
filepath.Join(rootPath, "settings.json"),
|
||||
filepath.Join(rootPath, "layout.json"),
|
||||
filepath.Join(rootPath, "catalog", "packs"),
|
||||
filepath.Join(rootPath, "catalog", "standalone"),
|
||||
filepath.Join(rootPath, "departments", "department-primary-department", "settings.json"),
|
||||
filepath.Join(rootPath, "departments", "department-primary-department", "users.json"),
|
||||
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "settings.json"),
|
||||
filepath.Join(rootPath, "departments", "department-primary-department", "teams", "team-primary-team", "users.json"),
|
||||
filepath.Join(rootPath, "projects", "project-primary-project", "settings.json"),
|
||||
filepath.Join(rootPath, "projects", "project-primary-project", "home.json"),
|
||||
filepath.Join(rootPath, "projects", "project-primary-project", "tree"),
|
||||
filepath.Join(rootPath, "users", "settings.json"),
|
||||
filepath.Join(rootPath, "users", "data.json"),
|
||||
filepath.Join(rootPath, "users", "personals"),
|
||||
}
|
||||
|
||||
for _, path := range requiredPaths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected path to exist %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
settingsPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "settings.json"))
|
||||
installationPayload, ok := settingsPayload["installation"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("settings.json missing installation object: %#v", settingsPayload)
|
||||
}
|
||||
if installationPayload["name"] != "MangoPig" {
|
||||
t.Fatalf("expected installation name MangoPig, got %#v", installationPayload["name"])
|
||||
}
|
||||
if installationPayload["isBootstrapped"] != true {
|
||||
t.Fatalf("expected installation to be bootstrapped, got %#v", installationPayload["isBootstrapped"])
|
||||
}
|
||||
|
||||
layoutPayload := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "layout.json"))
|
||||
homePayload, ok := layoutPayload["home"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("layout.json missing home object: %#v", layoutPayload)
|
||||
}
|
||||
if homePayload["defaultProjectSlug"] != "primary-project" {
|
||||
t.Fatalf("expected default project slug primary-project, got %#v", homePayload["defaultProjectSlug"])
|
||||
}
|
||||
|
||||
projectSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "projects", "project-primary-project", "settings.json"))
|
||||
if projectSettings["type"] != "project" {
|
||||
t.Fatalf("expected project settings type project, got %#v", projectSettings["type"])
|
||||
}
|
||||
|
||||
usersSettings := readJSONFileForTest[map[string]any](t, filepath.Join(rootPath, "users", "settings.json"))
|
||||
if usersSettings["primaryAdminId"] != "admin-1" {
|
||||
t.Fatalf("expected primary admin id admin-1, got %#v", usersSettings["primaryAdminId"])
|
||||
}
|
||||
}
|
||||
|
||||
func readJSONFileForTest[T any](t *testing.T, path string) T {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var payload T
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", path, err)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
APIPort string
|
||||
PostgresURL string
|
||||
ValkeyURL string
|
||||
POSIXRoot string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ func Load() *Config {
|
||||
APIPort: getEnv("BACKEND_API_PORT", "8081"),
|
||||
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
|
||||
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
|
||||
POSIXRoot: getEnv("POSIX_ROOT", "../POSIX"),
|
||||
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
func (routes apiRoutes) bootstrapService() *bootstrapservice.Service {
|
||||
return bootstrapservice.NewService(routes.cfg.Database)
|
||||
return bootstrapservice.NewService(routes.cfg.Database, routes.cfg.Config.POSIXRoot)
|
||||
}
|
||||
|
||||
func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) {
|
||||
|
||||
Reference in New Issue
Block a user