Skip to content
Open
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: 7 additions & 9 deletions microceph/api/certificates.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,14 @@ func cmdCertificatesRGWPut(s mcTypes.State, r *http.Request) mcTypes.Response {
return mcTypes.InternalError(err)
}

err = ceph.UpdateRGWCertificates(interfaces.CephState{State: s}, req.SSLCertificate, req.SSLPrivateKey)
// UpdateRGWCertificates handles the --restart flag itself: the restart is
// part of the same serialized lifecycle, so a failure cannot leave a
// published-but-unservable certificate behind. Input failures (bad base64,
// mismatched pair) carry ceph.ErrRgwFrontendInvalid and map to 400;
// operational failures map to 500 (see serviceErrorResponse).
err = ceph.UpdateRGWCertificates(r.Context(), interfaces.CephState{State: s}, req.SSLCertificate, req.SSLPrivateKey, req.Restart)
if err != nil {
return mcTypes.SmartError(err)
}

if req.Restart {
err = ceph.RestartRGW()
if err != nil {
return mcTypes.SmartError(err)
}
return serviceErrorResponse(err)
}

return mcTypes.EmptySyncResponse
Expand Down
43 changes: 43 additions & 0 deletions microceph/api/rgw_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package api

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

lxdAPI "github.com/canonical/lxd/shared/api"
mcTypes "github.com/canonical/microcluster/v3/microcluster/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand All @@ -17,6 +21,45 @@ import (
"github.com/canonical/microceph/microceph/interfaces"
)

func TestRGWInvalidFrontendSurvivesHTTPTransport(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := cmdEnableServicePut(nil, r)
err := response.Render(w, r)
if err != nil {
t.Errorf("rendering RGW response: %v", err)
}
}))
t.Cleanup(server.Close)

cases := []struct {
name string
payload string
}{
{"malformed payload", `{`},
{"null payload", `null`},
{"negative port", `{"Port":-1,"SSL":false}`},
{"TLS port overflow", `{"SSL":true,"SSLPort":65536}`},
{"same listener port", `{"Port":443,"SSL":true,"SSLPort":443}`},
{"missing private key", `{"SSL":true,"SSLCertificate":"Y2VydA=="}`},
{"invalid certificate", `{"SSL":true,"SSLCertificate":"!","SSLPrivateKey":"c2VjcmV0"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body, err := json.Marshal(types.EnableService{Name: "rgw", Wait: true, Payload: tc.payload})
require.NoError(t, err)
response, err := http.Post(server.URL+"/1.0/services/rgw", "application/json", bytes.NewReader(body))
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)

// Microcluster reconstructs the error from HTTP status, not error_code.
_, err = mcTypes.ParseResponse(response)
require.Error(t, err)
assert.True(t, lxdAPI.StatusErrorCheck(err, http.StatusBadRequest))
assert.NotContains(t, err.Error(), "c2VjcmV0")
})
}
}

// TestEnableServicePutResponses verifies the handler's HTTP status for placement
// outcomes, including failures that must not be returned as successful responses.
func TestEnableServicePutResponses(t *testing.T) {
Expand Down
11 changes: 6 additions & 5 deletions microceph/ceph/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,11 +439,12 @@ func UpdateConfig(ctx context.Context, s interfaces.StateInterface) error {
monitorAddresses = formatIPv6(monitorAddresses)

// Keep radosgw.conf's `mon host` line in sync with the current monitors.
// radosgw.conf is written once at RGW enable time (EnableRGW) and is never
// re-rendered from scratch, because the RGW frontend port/SSL settings are
// not persisted. We therefore rewrite only the mon host line in place,
// preserving the other settings. A missing file (RGW disabled) is a no-op;
// a failure here must not block the ceph.conf refresh below.
// radosgw.conf is owned by the RGW enable/update paths (applyRGWFrontend),
// which alone know the TLS material the frontend references, so it is not
// re-rendered from scratch here. We therefore rewrite only the mon host
// line in place, preserving the other settings. A missing file (RGW
// disabled) is a no-op; a failure here must not block the ceph.conf
// refresh below.
err = updateRadosGWMonHost(confPath, monitorAddresses)
if err != nil {
logger.Warnf("failed to refresh radosgw.conf mon host: %v", err)
Expand Down
19 changes: 19 additions & 0 deletions microceph/ceph/configwriter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ceph

import (
"bytes"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -32,6 +33,24 @@ func (c *Config) validateConfigFile() error {
return nil
}

// RenderConfig renders the configuration template with data into a byte slice
// without touching disk. A caller compares this against the current on-disk
// file to decide whether a rewrite (and service restart) is actually needed,
// the "render from dqlite, compare in place" idiom (AGENTS.md conf-file
// model). The output is byte-identical to WriteConfig for the same data.
func (c *Config) RenderConfig(data map[string]any) ([]byte, error) {
err := c.validateConfigFile()
if err != nil {
return nil, err
}
var buf bytes.Buffer
err = c.configTemplate.Execute(&buf, data)
if err != nil {
return nil, fmt.Errorf("Couldn't render %s: %w", c.configFile, err)
}
return buf.Bytes(), nil
}

// WriteConfig writes the configuration file given a data bag and a filemode.
// It writes atomically: the template is rendered to a uniquely-named temporary
// file (created via os.CreateTemp in the same directory as the destination) and
Expand Down
Loading
Loading