Feat: add POSIX-lite bootstrap foundation

This commit is contained in:
MangoPig
2026-06-21 21:02:59 +01:00
parent 626ae02df0
commit 5735e3008d
14 changed files with 385 additions and 10 deletions

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ tmp/
bin/ bin/
.cgcignore .cgcignore
POSIX/

View File

@@ -4,8 +4,11 @@ package bootstrap
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -41,7 +44,8 @@ var (
) )
type Service struct { type Service struct {
db *database.DB db *database.DB
posixRoot string
} }
type SaveInstanceInput struct { type SaveInstanceInput struct {
@@ -172,8 +176,8 @@ type namedRecord struct {
Slug string `json:"slug"` Slug string `json:"slug"`
} }
func NewService(db *database.DB) *Service { func NewService(db *database.DB, posixRoot string) *Service {
return &Service{db: db} return &Service{db: db, posixRoot: strings.TrimSpace(posixRoot)}
} }
func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) { 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 return StructureRecord{}, err
} }
if err := service.ensureBootstrapPOSIXSkeleton(installation, admin, organization, department, team, project); err != nil {
return StructureRecord{}, err
}
return StructureRecord{ return StructureRecord{
Installation: installation, Installation: installation,
Organization: organization, Organization: organization,
@@ -901,3 +909,173 @@ func personalHomeTitle(displayName string) string {
return fmt.Sprintf("%s's Home", trimmedDisplayName) 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)
}

View 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
}

View File

@@ -17,6 +17,7 @@ type Config struct {
APIPort string APIPort string
PostgresURL string PostgresURL string
ValkeyURL string ValkeyURL string
POSIXRoot string
ShutdownTimeout time.Duration ShutdownTimeout time.Duration
} }
@@ -29,6 +30,7 @@ func Load() *Config {
APIPort: getEnv("BACKEND_API_PORT", "8081"), APIPort: getEnv("BACKEND_API_PORT", "8081"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"), PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"), ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
POSIXRoot: getEnv("POSIX_ROOT", "../POSIX"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second), ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
} }
} }

View File

@@ -341,7 +341,7 @@ func (routes apiRoutes) handleBootstrapStructureStep(w http.ResponseWriter, r *h
} }
func (routes apiRoutes) bootstrapService() *bootstrapservice.Service { 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) { func (routes apiRoutes) writeBootstrapStepResponse(w http.ResponseWriter, status int, step string, payload any) {

View File

@@ -9,10 +9,14 @@ migrate-up:
migrate-down: migrate-down:
cd '{{backend_dir}}' && go run ./cmd/migrate down cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations and reapply from scratch. # Reset all embedded database migrations.
migrate-reset: migrate-reset:
cd '{{backend_dir}}' && go run ./cmd/migrate reset cd '{{backend_dir}}' && go run ./cmd/migrate reset
# Reset embedded database migrations and apply them again from scratch.
migrate-rebuild:
cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up
# Show the embedded database migration status. # Show the embedded database migration status.
migrate-status: migrate-status:
cd '{{backend_dir}}' && go run ./cmd/migrate status cd '{{backend_dir}}' && go run ./cmd/migrate status
@@ -20,7 +24,3 @@ migrate-status:
# Format backend Go source files. # Format backend Go source files.
fmt: fmt:
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...

View File

@@ -0,0 +1,11 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
# Run the full backend test suite.
[default]
all:
cd '{{backend_dir}}' && go test ./...
# Run the isolated POSIX bootstrap smoke test.
posix-bootstrap:
cd '{{backend_dir}}' && go test ./internal/bootstrap -run TestEnsureBootstrapPOSIXSkeletonInitializesEmptyRoot -count=1 -v

1
Commands/Test/mod.just Normal file
View File

@@ -0,0 +1 @@
mod backend

View File

@@ -6,6 +6,7 @@ x-backend-service: &backend-service
environment: environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0 VALKEY_URL: redis://valkey:6379/0
POSIX_ROOT: /posix
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -13,6 +14,7 @@ x-backend-service: &backend-service
condition: service_healthy condition: service_healthy
volumes: volumes:
- ../Backend:/app - ../Backend:/app
- ../POSIX:/posix
- moku_work_backend_go_pkg:/go/pkg/mod - moku_work_backend_go_pkg:/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build - moku_work_backend_go_build:/root/.cache/go-build

View File

@@ -5,11 +5,14 @@ x-backend-service: &backend-service
environment: environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0 VALKEY_URL: redis://valkey:6379/0
POSIX_ROOT: /posix
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
valkey: valkey:
condition: service_healthy condition: service_healthy
volumes:
- ../POSIX:/posix
services: services:
postgres: postgres:

View File

@@ -0,0 +1,61 @@
# POSIX Structure
``` markdown
Personal or Organization (server)/
├── settings.json
├── layout.json
├── catalog/
│ ├── packs/
│ │ └── pack-<slug>/
│ │ ├── manifest.json
│ │ └── entries/
│ │ └── app-<slug>/
│ │ └── manifest.json
│ └── standalone/
│ └── app-<slug>/
│ └── manifest.json
├── departments/
│ └── department-<slug>/
│ ├── settings.json
│ ├── users.json
│ └── teams/
│ └── team-<slug>/
│ ├── settings.json
│ └── users.json
├── projects/
│ └── project-<slug>/
│ ├── settings.json
│ ├── home.json
│ └── tree/
│ ├── item-<slug>/
│ │ ├── item.json
│ │ ├── schema.json
│ │ └── data.json
│ └── folder-<slug>/
│ ├── folder.json
│ └── item-<slug>/
│ ├── item.json
│ ├── schema.json
│ └── data.json
└── users/
├── settings.json
├── data.json
└── personals/
└── personal-<slug>/
├── layout.json
├── settings.json
├── home.json
└── tree/
```
## File Responsibilities
- `settings.json` — Metadata and presentation config for the thing, such as display name, icon, description, and simple settings.
- `layout.json` — Layout configuration for the current server or personal space.
- `home.json` — Home surface configuration, such as widgets, sections, and how they are arranged.
- `folder.json` — Metadata for a folder node in a tree.
- `item.json` — Instance metadata for a created item, including what it is and how it should behave.
- `schema.json` — The structure expected by that item's data.
- `data.json` — The actual content or state data for that item.
- `manifest.json` — Catalog definition metadata, including versioning, description, and capabilities for reusable apps or entries.
- `users.json` — User membership or assignment data for departments and teams.

View File

@@ -137,7 +137,7 @@
### Version 0.4.0 ### Version 0.4.0
**Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage **Goal:** Introduce the POSIX-based file system drive direction with OnlyOffice + S3 blob storage + Per File Versioning
### Version 0.5.0 ### Version 0.5.0

View File

@@ -10,6 +10,7 @@ BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0 VALKEY_URL=redis://localhost:6379/0
POSIX_ROOT=../POSIX
VITE_API_BASE_URL=/v1 VITE_API_BASE_URL=/v1

View File

@@ -1,6 +1,7 @@
set shell := ["bash", "-cu"] set shell := ["bash", "-cu"]
mod local "Commands/Local" mod local "Commands/Local"
mod test "Commands/Test"
[default] [default]
help: help: