From 40aee192a68ec335de60d69d5f45dc10dd0f62c6 Mon Sep 17 00:00:00 2001 From: haramjeong <04harams77@gmail.com> Date: Tue, 29 Sep 2026 11:34:28 +0900 Subject: [PATCH 1/2] fix: resolve function database paths and default builder socket --- README.md | 16 +++- internal/domain/functions/builder.go | 4 +- internal/domain/functions/builder_test.go | 79 +++++++++++++++++++ internal/domain/functions/data.go | 7 ++ internal/domain/functions/handler.go | 2 + .../domain/functions/sql_database_test.go | 24 ++++++ 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 internal/domain/functions/builder_test.go diff --git a/README.md b/README.md index b0e658c..8cafce3 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ bash setup.sh sudo bash install.sh /path/to/function-builder ``` -빌드 컨테이너는 네트워크 없이 제한된 자원으로 실행합니다. systemd 서비스는 별도 `return-builder` 계정으로 실행되며 API 계정 `return`은 Unix 소켓에만 접근합니다. API 환경 변수 또는 배포 시크릿 `RCP_FUNCTION_BUILDER_SOCKET=/run/rcp-function-builder/builder.sock`를 설정하고 API 서비스를 재시작합니다. 빌더가 설정되지 않으면 소스 업로드는 503을 반환하며 `.wasm` 직접 업로드는 계속 사용할 수 있습니다. CI는 이미 설치된 빌더 서비스의 바이너리만 갱신합니다. +빌드 컨테이너는 네트워크 없이 제한된 자원으로 실행합니다. systemd 서비스는 별도 `return-builder` 계정으로 실행되며 API 계정 `return`은 Unix 소켓에만 접근합니다. API 환경 변수 또는 배포 시크릿 `RCP_FUNCTION_BUILDER_SOCKET=/run/rcp-function-builder/builder.sock`를 설정하고 API 서비스를 재시작합니다. API는 `RCP_FUNCTION_BUILDER_SOCKET`이 비어 있으면 기본 소켓 `/run/rcp-function-builder/builder.sock`에 연결합니다. 빌더가 설치되지 않았거나 실행 중이 아니면 소스 업로드는 503을 반환하며 `.wasm` 직접 업로드는 계속 사용할 수 있습니다. CI는 이미 설치된 빌더 서비스의 바이너리만 갱신합니다. ### 함수 데이터 (SQLite) @@ -161,3 +161,17 @@ ssh rcp-gw - OpenStack 호출은 Cloudflare Access 헤더가 포함된 HTTP 클라이언트를 통해 수행됩니다. - 단위 테스트는 `go test ./...`로 실행합니다 — `cmd/ns-proxy`, `cmd/ssh-gateway`, `internal/domain/{auth,access,compute,storage}`, `internal/server` 등에 커버리지가 있습니다. + +### Functions 장애 확인 + +소스 업로드의 503은 API 배포만으로 빌드 도구와 서비스가 설치되지 않는 경우에도 발생합니다. 최초 설치는 위 빌드 서비스 절의 `setup.sh`와 `install.sh`를 실행해야 하며, API 프로세스에 Docker 권한을 추가하지 않습니다. 이후 다음을 확인합니다. + +```bash +sudo systemctl status rcp-function-builder --no-pager +sudo journalctl -u rcp-function-builder -n 80 --no-pager +sudo journalctl -u rcp-server -n 80 --no-pager +``` + +표준 systemd unit과 다른 소켓 경로를 사용하면 API의 `RCP_FUNCTION_BUILDER_SOCKET`도 맞춰야 합니다. `.wasm` 직접 업로드는 소스 빌더를 사용하지 않습니다. + +데이터베이스 API의 500 응답은 내부 오류를 공개하지 않습니다. 원인은 API 서버 로그에서 확인합니다. 함수 데이터 경로는 시작 시 절대 경로로 고정되므로 기본 상대 경로 `function-data`도 사용할 수 있습니다. diff --git a/internal/domain/functions/builder.go b/internal/domain/functions/builder.go index 6ed7857..e1977fc 100644 --- a/internal/domain/functions/builder.go +++ b/internal/domain/functions/builder.go @@ -12,6 +12,8 @@ import ( "time" ) +const defaultBuilderSocket = "/run/rcp-function-builder/builder.sock" + type Builder interface { Build(context.Context, string, []byte) ([]byte, error) } @@ -56,7 +58,7 @@ func (b *SocketBuilder) Build(ctx context.Context, language string, source []byt func BuilderFromEnv() (Builder, error) { path := os.Getenv("RCP_FUNCTION_BUILDER_SOCKET") if path == "" { - return nil, nil + path = defaultBuilderSocket } // #nosec G703 -- this path is supplied by trusted deployment configuration. if _, err := os.Stat(path); err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/internal/domain/functions/builder_test.go b/internal/domain/functions/builder_test.go new file mode 100644 index 0000000..41120b0 --- /dev/null +++ b/internal/domain/functions/builder_test.go @@ -0,0 +1,79 @@ +package functions + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuilderFromEnvUsesDefaultSocket(t *testing.T) { + t.Setenv("RCP_FUNCTION_BUILDER_SOCKET", "") + builder, err := BuilderFromEnv() + if err != nil { + t.Fatal(err) + } + if builder == nil { + t.Fatal("empty configuration disabled source builds") + } + // The default need not be installed on developer machines. Check where the + // configured transport tries to connect without compiling any user code. + transport := builder.(*SocketBuilder).client.Transport.(*http.Transport) + conn, err := transport.DialContext(context.Background(), "tcp", "unused") + if err == nil { + _ = conn.Close() + return + } + if !strings.Contains(err.Error(), defaultBuilderSocket) { + t.Fatalf("unexpected socket: %v", err) + } +} + +func TestSocketBuilderUploadsSource(t *testing.T) { + // Keep the Unix socket below platform path-length limits. + dir, err := os.MkdirTemp("", "rcp-bld-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "b.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPost || r.URL.Path != "/build" || r.URL.Query().Get("language") != "python" || string(body) != "print(1)" { + t.Errorf("unexpected build request: %s %s %q", r.Method, r.URL, body) + } + _, _ = w.Write([]byte("wasm")) + })} + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { _ = server.Close() }) + t.Setenv("RCP_FUNCTION_BUILDER_SOCKET", path) + builder, err := BuilderFromEnv() + if err != nil { + t.Fatal(err) + } + wasm, err := builder.Build(context.Background(), "python", []byte("print(1)")) + if err != nil || string(wasm) != "wasm" { + t.Fatalf("build: %q, %v", wasm, err) + } +} + +func TestMissingBuilderDoesNotPreventWasmOnlyStartup(t *testing.T) { + t.Setenv("RCP_FUNCTION_BUILDER_SOCKET", filepath.Join(t.TempDir(), "missing.sock")) + builder, err := BuilderFromEnv() + if err != nil { + t.Fatal(err) + } + _, err = builder.Build(context.Background(), "python", []byte("print(1)")) + if !errors.Is(err, ErrBuildUnavailable) { + t.Fatalf("missing builder: %v", err) + } +} diff --git a/internal/domain/functions/data.go b/internal/domain/functions/data.go index 0228e37..6172fc0 100644 --- a/internal/domain/functions/data.go +++ b/internal/domain/functions/data.go @@ -41,6 +41,13 @@ type DataStore struct { } func OpenDataStore(dir string) (*DataStore, error) { + // SQLite file URIs require an absolute path. Keep metadata and bound databases + // rooted in the same directory even when configuration uses a relative path. + var err error + dir, err = filepath.Abs(dir) + if err != nil { + return nil, err + } // #nosec G703 -- dir is fixed at startup from trusted deployment configuration. if err := os.MkdirAll(dir, 0700); err != nil { return nil, err diff --git a/internal/domain/functions/handler.go b/internal/domain/functions/handler.go index c07026e..2bbaee6 100644 --- a/internal/domain/functions/handler.go +++ b/internal/domain/functions/handler.go @@ -53,6 +53,8 @@ func ownerAndID(c *gin.Context) (uuid.UUID, uuid.UUID, bool) { } func writeError(c *gin.Context, err error) { + // Preserve the underlying error for server-side diagnostics; responses stay sanitized. + _ = c.Error(err) status := http.StatusInternalServerError switch { case errors.Is(err, ErrNotFound), errors.Is(err, ErrDataNotFound): diff --git a/internal/domain/functions/sql_database_test.go b/internal/domain/functions/sql_database_test.go index a4eb31f..f6d2041 100644 --- a/internal/domain/functions/sql_database_test.go +++ b/internal/domain/functions/sql_database_test.go @@ -224,3 +224,27 @@ func TestDatabaseHTTPBindingsAndAuthorization(t *testing.T) { t.Fatalf("unsafe SQL accepted: %d %s", got.Code, got.Body.String()) } } + +func TestDatabaseWithRelativeDataDirectory(t *testing.T) { + t.Chdir(t.TempDir()) + store, err := OpenDataStore("function-data") + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + owner := uuid.New() + database, err := store.CreateDatabase(context.Background(), owner, "counter-db") + if err != nil { + t.Fatal(err) + } + if _, err := store.QueryDatabase(context.Background(), owner, database.ID, "CREATE TABLE visits (id INTEGER PRIMARY KEY)", nil); err != nil { + t.Fatal(err) + } + if _, err := store.QueryDatabase(context.Background(), owner, database.ID, "INSERT INTO visits DEFAULT VALUES", nil); err != nil { + t.Fatal(err) + } + result, err := store.QueryDatabase(context.Background(), owner, database.ID, "SELECT COUNT(*) AS count FROM visits", nil) + if err != nil || len(result.Rows) != 1 { + t.Fatalf("query: %+v, %v", result, err) + } +} From c999b02a98d72aea395b1fcbbf743cb78f63473a Mon Sep 17 00:00:00 2001 From: haramjeong <04harams77@gmail.com> Date: Tue, 29 Sep 2026 11:36:47 +0900 Subject: [PATCH 2/2] test: bound build stub HTTP header reads --- internal/domain/functions/builder_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/domain/functions/builder_test.go b/internal/domain/functions/builder_test.go index 41120b0..a7c5c4a 100644 --- a/internal/domain/functions/builder_test.go +++ b/internal/domain/functions/builder_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestBuilderFromEnvUsesDefaultSocket(t *testing.T) { @@ -46,7 +47,7 @@ func TestSocketBuilderUploadsSource(t *testing.T) { if err != nil { t.Fatal(err) } - server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if r.Method != http.MethodPost || r.URL.Path != "/build" || r.URL.Query().Get("language") != "python" || string(body) != "print(1)" { t.Errorf("unexpected build request: %s %s %q", r.Method, r.URL, body)