Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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`도 사용할 수 있습니다.
4 changes: 3 additions & 1 deletion internal/domain/functions/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"time"
)

const defaultBuilderSocket = "/run/rcp-function-builder/builder.sock"

type Builder interface {
Build(context.Context, string, []byte) ([]byte, error)
}
Expand Down Expand Up @@ -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) {
Expand Down
80 changes: 80 additions & 0 deletions internal/domain/functions/builder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package functions

import (
"context"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

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{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)
}
_, _ = 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)
}
}
7 changes: 7 additions & 0 deletions internal/domain/functions/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/domain/functions/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
24 changes: 24 additions & 0 deletions internal/domain/functions/sql_database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading