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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `kasapi-cli subdomains get <name>` calls `get_subdomains` with a
`subdomain_name` filter and unwraps the single-entry result, mirroring
the existing `domains get` flow; the singular `Subdomain` value
renders as a key/value table with the SSL cert/key/CSR PEM bodies
summarised as `<bytes,lines>`.

- `internal/domain`, `internal/subdomain`, and `internal/dns` read
modules with the matching CLI subcommand trees: `kasapi-cli domains
list` and `domains get <name>` (`get_domains`, the latter passing a
Expand Down
29 changes: 27 additions & 2 deletions internal/cli/subdomains.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import (
)

// NewSubdomainsCmd returns the "kasapi-cli subdomains" subcommand
// tree: list (get_subdomains).
// tree: list (get_subdomains), get (get_subdomains with subdomain_name).
func NewSubdomainsCmd(opts *RootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "subdomains",
Short: "Inspect subdomains owned by the authenticated account",
}
cmd.AddCommand(newSubdomainsListCmd(opts))
cmd.AddCommand(
newSubdomainsListCmd(opts),
newSubdomainsGetCmd(opts),
)
return cmd
}

Expand All @@ -38,3 +41,25 @@ func newSubdomainsListCmd(opts *RootOptions) *cobra.Command {
},
}
}

func newSubdomainsGetCmd(opts *RootOptions) *cobra.Command {
return &cobra.Command{
Use: "get <subdomain>",
Short: "Show details for a single subdomain (get_subdomains with subdomain_name)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
api, err := BuildAPIClient(opts)
if err != nil {
return err
}
s, err := subdomain.NewClient(api).Get(cmd.Context(), args[0])
if err != nil {
return APIError(err, "get_subdomains")
}
if err := Render(cmd.OutOrStdout(), opts.Output, s); err != nil {
return UserError(err, "render")
}
return nil
},
}
}
90 changes: 84 additions & 6 deletions internal/subdomain/subdomain.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ type Caller interface {
Call(ctx context.Context, action string, params map[string]any) (*soap.Response, error)
}

// Subdomain is one entry of get_subdomains. The shape mirrors the
// domain list view (same SSL summary, same lifecycle flags) but is
// keyed on subdomain_* fields.
// Subdomain is one entry of get_subdomains. The KAS list view exposes
// the account/server placement and a flattened SSL summary; the
// singular view (get_subdomains with a subdomain_name filter) returns
// the same Map shape but additionally fills the SSL cert/key/CSR PEM
// bodies that the list view leaves as xsi:nil. We model both shapes
// with one struct and rely on `omitempty` for the cert bodies.
type Subdomain struct {
Name string `json:"subdomain_name" yaml:"subdomain_name"`
RedirectStatus int `json:"subdomain_redirect_status" yaml:"subdomain_redirect_status"`
Expand Down Expand Up @@ -60,16 +63,18 @@ type SSL struct {
// cli.Tabular.
type SubdomainList []Subdomain

// Client groups the read endpoints scoped to subdomains: get_subdomains.
// Client groups the read endpoints scoped to subdomains:
// get_subdomains (list and singular).
type Client struct {
API Caller
}

// NewClient returns a Client backed by the given Caller.
func NewClient(c Caller) *Client { return &Client{API: c} }

// List calls get_subdomains and decodes the response into a
// SubdomainList. The KAS API accepts no parameters on this endpoint.
// List calls get_subdomains without parameters and decodes the
// response into a SubdomainList covering every subdomain visible to
// the login.
func (c *Client) List(ctx context.Context) (SubdomainList, error) {
resp, err := c.API.Call(ctx, "get_subdomains", nil)
if err != nil {
Expand All @@ -82,6 +87,28 @@ func (c *Client) List(ctx context.Context) (SubdomainList, error) {
return list, nil
}

// Get calls get_subdomains with a subdomain_name filter and returns
// the single matching Subdomain. The KAS API still wraps the result
// in an array; we unwrap it here so callers do not have to. An empty
// array surfaces as a not-found error.
func (c *Client) Get(ctx context.Context, name string) (Subdomain, error) {
if name == "" {
return Subdomain{}, fmt.Errorf("subdomain: name is required")
}
resp, err := c.API.Call(ctx, "get_subdomains", map[string]any{"subdomain_name": name})
if err != nil {
return Subdomain{}, err
}
list, err := DecodeSubdomains(resp.Body.ReturnInfo)
if err != nil {
return Subdomain{}, fmt.Errorf("subdomain: get_subdomains: %w", err)
}
if len(list) == 0 {
return Subdomain{}, fmt.Errorf("subdomain: %q not found", name)
}
return list[0], nil
}

// DecodeSubdomains maps the ReturnInfo of a get_subdomains response
// (an Array of Maps) into the typed SubdomainList.
func DecodeSubdomains(returnInfo soap.Value) (SubdomainList, error) {
Expand Down Expand Up @@ -181,3 +208,54 @@ func (l SubdomainList) TableRows() [][]string {
}
return rows
}

// TableHeaders for the singular Subdomain view: a key/value layout,
// since the record may carry an SSL cert PEM blob and is too tall for
// a row.
func (Subdomain) TableHeaders() []string {
return []string{"FIELD", "VALUE"}
}

// TableRows emits the scalar fields. The SSL cert PEM bodies are
// summarised; consumers that need them should use --output=json|yaml.
func (s Subdomain) TableRows() [][]string {
return [][]string{
{"subdomain_name", s.Name},
{"subdomain_account", s.Account},
{"subdomain_server", s.Server},
{"subdomain_path", s.Path},
{"subdomain_redirect_status", strconv.Itoa(s.RedirectStatus)},
{"fpse_active", s.FPSEActive},
{"php_version", s.PHPVersion},
{"php_deprecated", s.PHPDeprecated},
{"is_active", s.IsActive},
{"in_progress", s.InProgress},
{"statistic_version", strconv.Itoa(s.StatisticVersion)},
{"statistic_language", s.StatisticLanguage},
{"ssl_proxy", s.SSL.Proxy},
{"ssl_certificate_ip", s.SSL.CertificateIP},
{"ssl_certificate_sni", s.SSL.SNI},
{"ssl_certificate_sni_is_active", s.SSL.SNIIsActive},
{"ssl_certificate_sni_type", s.SSL.SNIType},
{"ssl_certificate_sni_force_https", s.SSL.SNIForceHTTPS},
{"ssl_certificate_sni_hsts_max_age", s.SSL.SNIHSTSMaxAge},
{"ssl_certificate_sni_csr", summarisePEM(s.SSL.SNICSR)},
{"ssl_certificate_sni_key", summarisePEM(s.SSL.SNIKey)},
{"ssl_certificate_sni_crt", summarisePEM(s.SSL.SNICRT)},
{"ssl_certificate_sni_bundle", summarisePEM(s.SSL.SNIBundle)},
{"ssl_certificate_sni_chainfile", summarisePEM(s.SSL.SNIChainfile)},
}
}

// summarisePEM collapses a multi-line PEM blob to a single line marker
// so the key/value table stays readable. Empty input passes through
// unchanged.
func summarisePEM(s string) string {
if s == "" {
return ""
}
if !strings.Contains(s, "\n") {
return s
}
return fmt.Sprintf("<%d bytes, %d lines>", len(s), strings.Count(s, "\n")+1)
}
63 changes: 63 additions & 0 deletions internal/subdomain/subdomain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,76 @@ func TestClientList(t *testing.T) {
}
}

func TestClientGet(t *testing.T) {
t.Parallel()
resp := decodeFixture(t, "get_subdomains_response_success.xml")
fc := &fakeCaller{resp: resp}
s, err := subdomain.NewClient(fc).Get(context.Background(), "sub1.example.com")
if err != nil {
t.Fatalf("Get: %v", err)
}
if fc.gotAction != "get_subdomains" {
t.Errorf("action = %q, want get_subdomains", fc.gotAction)
}
if name, _ := fc.gotParams["subdomain_name"].(string); name != "sub1.example.com" {
t.Errorf("params[subdomain_name] = %v, want sub1.example.com", fc.gotParams["subdomain_name"])
}
// The fixture is the list-view payload; Get unwraps the first entry.
if s.Name != "sub1.example.com" {
t.Errorf("Name = %q", s.Name)
}
}

func TestClientGetEmptyName(t *testing.T) {
t.Parallel()
c := subdomain.NewClient(&fakeCaller{})
if _, err := c.Get(context.Background(), ""); err == nil {
t.Errorf("Get(\"\") err = nil, want validation error")
}
}

func TestClientGetNotFound(t *testing.T) {
t.Parallel()
resp := &soap.Response{Body: soap.ResponseBody{ReturnInfo: soap.Value{Kind: soap.KindArray}}}
c := subdomain.NewClient(&fakeCaller{resp: resp})
if _, err := c.Get(context.Background(), "missing.example.com"); err == nil {
t.Errorf("Get on empty result err = nil, want not-found")
}
}

func TestClientPropagatesError(t *testing.T) {
t.Parallel()
want := errors.New("boom")
c := subdomain.NewClient(&fakeCaller{err: want})
if _, err := c.List(context.Background()); !errors.Is(err, want) {
t.Errorf("List err = %v, want %v wrapped", err, want)
}
if _, err := c.Get(context.Background(), "x.example.com"); !errors.Is(err, want) {
t.Errorf("Get err = %v, want %v wrapped", err, want)
}
}

func TestSubdomainTabularKeyValue(t *testing.T) {
t.Parallel()
resp := decodeFixture(t, "get_subdomains_response_success.xml")
list, _ := subdomain.DecodeSubdomains(resp.Body.ReturnInfo)
if len(list) == 0 {
t.Fatal("fixture empty")
}
headers := list[0].TableHeaders()
if len(headers) != 2 || headers[0] != "FIELD" || headers[1] != "VALUE" {
t.Errorf("headers = %v, want [FIELD VALUE]", headers)
}
rows := list[0].TableRows()
var seen bool
for _, row := range rows {
if row[0] == "subdomain_name" && row[1] == "sub1.example.com" {
seen = true
}
}
if !seen {
t.Errorf("subdomain_name row missing or wrong: %v", rows)
}
}

func TestSubdomainListTabular(t *testing.T) {
Expand Down
Loading