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
47 changes: 47 additions & 0 deletions server/internal/orchestrator/swarm/postgrest_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package swarm

import (
"bytes"
"fmt"

"github.com/pgEdge/control-plane/server/internal/database"
)

// PostgRESTConfigParams holds all inputs needed to generate a postgrest.conf file.
type PostgRESTConfigParams struct {
Config *database.PostgRESTServiceConfig
}

// GeneratePostgRESTConfig generates the postgrest.conf file content.
// Credentials are not written here; they are injected as libpq env vars at the container level.
func GeneratePostgRESTConfig(params *PostgRESTConfigParams) ([]byte, error) {
if params == nil {
return nil, fmt.Errorf("GeneratePostgRESTConfig: params must not be nil")
}
if params.Config == nil {
return nil, fmt.Errorf("GeneratePostgRESTConfig: params.Config must not be nil")
}
cfg := params.Config

var buf bytes.Buffer

fmt.Fprintf(&buf, "db-schemas = %q\n", cfg.DBSchemas)
fmt.Fprintf(&buf, "db-anon-role = %q\n", cfg.DBAnonRole)
fmt.Fprintf(&buf, "db-pool = %d\n", cfg.DBPool)
fmt.Fprintf(&buf, "db-max-rows = %d\n", cfg.MaxRows)
Comment on lines +17 to +31

if cfg.JWTSecret != nil {
fmt.Fprintf(&buf, "jwt-secret = %q\n", *cfg.JWTSecret)
}
if cfg.JWTAud != nil {
fmt.Fprintf(&buf, "jwt-aud = %q\n", *cfg.JWTAud)
}
if cfg.JWTRoleClaimKey != nil {
fmt.Fprintf(&buf, "jwt-role-claim-key = %q\n", *cfg.JWTRoleClaimKey)
}
if cfg.ServerCORSAllowedOrigins != nil {
fmt.Fprintf(&buf, "server-cors-allowed-origins = %q\n", *cfg.ServerCORSAllowedOrigins)
}

return buf.Bytes(), nil
}
132 changes: 132 additions & 0 deletions server/internal/orchestrator/swarm/postgrest_config_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package swarm

import (
"context"
"fmt"
"path/filepath"

"github.com/samber/do"
"github.com/spf13/afero"

"github.com/pgEdge/control-plane/server/internal/database"
"github.com/pgEdge/control-plane/server/internal/filesystem"
"github.com/pgEdge/control-plane/server/internal/resource"
)

var _ resource.Resource = (*PostgRESTConfigResource)(nil)

const ResourceTypePostgRESTConfig resource.Type = "swarm.postgrest_config"

func PostgRESTConfigResourceIdentifier(serviceInstanceID string) resource.Identifier {
return resource.Identifier{
ID: serviceInstanceID,
Type: ResourceTypePostgRESTConfig,
}
}

// PostgRESTConfigResource manages the postgrest.conf file on the host filesystem.
// The file is bind-mounted read-only into the container; credentials are not included.
type PostgRESTConfigResource struct {
ServiceInstanceID string `json:"service_instance_id"`
ServiceID string `json:"service_id"`
HostID string `json:"host_id"`
DirResourceID string `json:"dir_resource_id"`
Config *database.PostgRESTServiceConfig `json:"config"`
}

func (r *PostgRESTConfigResource) ResourceVersion() string {
return "1"
}

func (r *PostgRESTConfigResource) DiffIgnore() []string {
return nil
}

func (r *PostgRESTConfigResource) Identifier() resource.Identifier {
return PostgRESTConfigResourceIdentifier(r.ServiceInstanceID)
}

func (r *PostgRESTConfigResource) Executor() resource.Executor {
return resource.HostExecutor(r.HostID)
}

func (r *PostgRESTConfigResource) Dependencies() []resource.Identifier {
return []resource.Identifier{
filesystem.DirResourceIdentifier(r.DirResourceID),
}
}

func (r *PostgRESTConfigResource) TypeDependencies() []resource.Type {
return nil
}

func (r *PostgRESTConfigResource) Refresh(ctx context.Context, rc *resource.Context) error {
fs, err := do.Invoke[afero.Fs](rc.Injector)
if err != nil {
return err
}

dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID)
if err != nil {
return fmt.Errorf("failed to get service data dir path: %w", err)
}

_, err = readResourceFile(fs, filepath.Join(dirPath, "postgrest.conf"))
if err != nil {
return fmt.Errorf("failed to read PostgREST config: %w", err)
}

return nil
}

func (r *PostgRESTConfigResource) Create(ctx context.Context, rc *resource.Context) error {
fs, err := do.Invoke[afero.Fs](rc.Injector)
if err != nil {
return err
}

dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID)
if err != nil {
return fmt.Errorf("failed to get service data dir path: %w", err)
}

return r.writeConfigFile(fs, dirPath)
}

func (r *PostgRESTConfigResource) Update(ctx context.Context, rc *resource.Context) error {
fs, err := do.Invoke[afero.Fs](rc.Injector)
if err != nil {
return err
}

dirPath, err := filesystem.DirResourceFullPath(rc, r.DirResourceID)
if err != nil {
return fmt.Errorf("failed to get service data dir path: %w", err)
}

return r.writeConfigFile(fs, dirPath)
}

func (r *PostgRESTConfigResource) Delete(ctx context.Context, rc *resource.Context) error {
// Cleanup is handled by the parent directory resource deletion.
return nil
}

func (r *PostgRESTConfigResource) writeConfigFile(fs afero.Fs, dirPath string) error {
content, err := GeneratePostgRESTConfig(&PostgRESTConfigParams{
Config: r.Config,
})
if err != nil {
return fmt.Errorf("failed to generate PostgREST config: %w", err)
}

configPath := filepath.Join(dirPath, "postgrest.conf")
if err := afero.WriteFile(fs, configPath, content, 0o600); err != nil {
return fmt.Errorf("failed to write %s: %w", configPath, err)
}
Comment on lines +123 to +126
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jason-lynch
Can you guide for this issue?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an accurate comment. What it's telling you is that in order for the postgrest container to read this file, it needs to be owned by the user that postgrest runs as. It also gives you a suggestion to explicitly set the UID for the container, which is a good suggestion.

If you look at the official postgrest Dockerfile: https://github.com/PostgREST/postgrest/blob/main/Dockerfile

It says:

USER 1000

Which means that postgrest runs with UID = 1000 by default. What I would do in this case is to do the chown, like Copilot suggests, and I would do it with UID and GID = 1000. You can find a few examples of that operation by searching the code for fs.Chown, such as this one in the DirResource that copilot mentions:

if err := fs.Chown(d.FullPath, d.OwnerUID, d.OwnerGID); err != nil {
return fmt.Errorf("failed to change ownership for directory %q: %w", d.FullPath, err)

I'd also take its other suggestion, and explicitly set the container UID to 1000. Since that's also the default, it doesn't have any effect now, but it protects us in case that container image changes its UID.

if err := fs.Chown(configPath, postgrestContainerUID, postgrestContainerUID); err != nil {
return fmt.Errorf("failed to change ownership for %s: %w", configPath, err)
}

return nil
}
186 changes: 186 additions & 0 deletions server/internal/orchestrator/swarm/postgrest_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package swarm

import (
"strings"
"testing"

"github.com/pgEdge/control-plane/server/internal/database"
)

// parseConf parses the key=value lines from a postgrest.conf into a map.
// String values are returned unquoted; numeric values are returned as-is.
func parseConf(t *testing.T, data []byte) map[string]string {
t.Helper()
m := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, " = ", 2)
if len(parts) != 2 {
t.Fatalf("unexpected line in postgrest.conf: %q", line)
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
// Strip surrounding quotes from string values.
if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) {
val = val[1 : len(val)-1]
}
m[key] = val
}
return m
}

func TestGeneratePostgRESTConfig_Defaults(t *testing.T) {
params := &PostgRESTConfigParams{
Config: &database.PostgRESTServiceConfig{
DBSchemas: "public",
DBAnonRole: "pgedge_application_read_only",
DBPool: 10,
MaxRows: 1000,
},
}

data, err := GeneratePostgRESTConfig(params)
if err != nil {
t.Fatalf("GeneratePostgRESTConfig() error = %v", err)
}

m := parseConf(t, data)

if m["db-schemas"] != "public" {
t.Errorf("db-schemas = %q, want %q", m["db-schemas"], "public")
}
if m["db-anon-role"] != "pgedge_application_read_only" {
t.Errorf("db-anon-role = %q, want %q", m["db-anon-role"], "pgedge_application_read_only")
}
if m["db-pool"] != "10" {
t.Errorf("db-pool = %q, want %q", m["db-pool"], "10")
}
if m["db-max-rows"] != "1000" {
t.Errorf("db-max-rows = %q, want %q", m["db-max-rows"], "1000")
}
}

func TestGeneratePostgRESTConfig_CustomCoreFields(t *testing.T) {
params := &PostgRESTConfigParams{
Config: &database.PostgRESTServiceConfig{
DBSchemas: "api,private",
DBAnonRole: "web_anon",
DBPool: 5,
MaxRows: 500,
},
}

data, err := GeneratePostgRESTConfig(params)
if err != nil {
t.Fatalf("GeneratePostgRESTConfig() error = %v", err)
}

m := parseConf(t, data)

if m["db-schemas"] != "api,private" {
t.Errorf("db-schemas = %q, want %q", m["db-schemas"], "api,private")
}
if m["db-anon-role"] != "web_anon" {
t.Errorf("db-anon-role = %q, want %q", m["db-anon-role"], "web_anon")
}
if m["db-pool"] != "5" {
t.Errorf("db-pool = %q, want %q", m["db-pool"], "5")
}
if m["db-max-rows"] != "500" {
t.Errorf("db-max-rows = %q, want %q", m["db-max-rows"], "500")
}
}

func TestGeneratePostgRESTConfig_JWTFieldsAbsent(t *testing.T) {
// No JWT fields set — none should appear in the config file.
params := &PostgRESTConfigParams{
Config: &database.PostgRESTServiceConfig{
DBSchemas: "public",
DBAnonRole: "web_anon",
DBPool: 10,
MaxRows: 1000,
},
}

data, err := GeneratePostgRESTConfig(params)
if err != nil {
t.Fatalf("GeneratePostgRESTConfig() error = %v", err)
}

m := parseConf(t, data)

for _, key := range []string{"jwt-secret", "jwt-aud", "jwt-role-claim-key", "server-cors-allowed-origins"} {
if _, ok := m[key]; ok {
t.Errorf("%s should be absent when not configured, but it was present", key)
}
}
}

func TestGeneratePostgRESTConfig_AllJWTFields(t *testing.T) {
secret := "a-very-long-jwt-secret-that-is-at-least-32-chars"
aud := "my-api-audience"
roleClaimKey := ".role"
corsOrigins := "https://example.com"

params := &PostgRESTConfigParams{
Config: &database.PostgRESTServiceConfig{
DBSchemas: "public",
DBAnonRole: "web_anon",
DBPool: 10,
MaxRows: 1000,
JWTSecret: &secret,
JWTAud: &aud,
JWTRoleClaimKey: &roleClaimKey,
ServerCORSAllowedOrigins: &corsOrigins,
},
}

data, err := GeneratePostgRESTConfig(params)
if err != nil {
t.Fatalf("GeneratePostgRESTConfig() error = %v", err)
}

m := parseConf(t, data)

if m["jwt-secret"] != secret {
t.Errorf("jwt-secret = %q, want %q", m["jwt-secret"], secret)
}
if m["jwt-aud"] != aud {
t.Errorf("jwt-aud = %q, want %q", m["jwt-aud"], aud)
}
if m["jwt-role-claim-key"] != roleClaimKey {
t.Errorf("jwt-role-claim-key = %q, want %q", m["jwt-role-claim-key"], roleClaimKey)
}
if m["server-cors-allowed-origins"] != corsOrigins {
t.Errorf("server-cors-allowed-origins = %q, want %q", m["server-cors-allowed-origins"], corsOrigins)
}
}

func TestGeneratePostgRESTConfig_CredentialsNotInFile(t *testing.T) {
// Verify that no credential-like keys ever appear in the config file.
secret := "a-very-long-jwt-secret-that-is-at-least-32-chars"
params := &PostgRESTConfigParams{
Config: &database.PostgRESTServiceConfig{
DBSchemas: "public",
DBAnonRole: "web_anon",
DBPool: 10,
MaxRows: 1000,
JWTSecret: &secret,
},
}

data, err := GeneratePostgRESTConfig(params)
if err != nil {
t.Fatalf("GeneratePostgRESTConfig() error = %v", err)
}

// None of the libpq / db-uri credential keys should appear.
for _, forbidden := range []string{"db-uri", "PGUSER", "PGPASSWORD", "PGHOST", "PGPORT", "PGDATABASE"} {
if strings.Contains(string(data), forbidden) {
t.Errorf("config file must not contain %q (credentials are env vars)", forbidden)
}
}
}
1 change: 1 addition & 0 deletions server/internal/orchestrator/swarm/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ func RegisterResourceTypes(registry *resource.Registry) {
resource.RegisterResourceType[*ScaleService](registry, ResourceTypeScaleService)
resource.RegisterResourceType[*MCPConfigResource](registry, ResourceTypeMCPConfig)
resource.RegisterResourceType[*PostgRESTPreflightResource](registry, ResourceTypePostgRESTPreflightResource)
resource.RegisterResourceType[*PostgRESTConfigResource](registry, ResourceTypePostgRESTConfig)
}
Loading