diff --git a/CHANGELOG.md b/CHANGELOG.md index 6398a59..2ce055b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `kasapi-cli subdomains get ` 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 ``. + - `internal/domain`, `internal/subdomain`, and `internal/dns` read modules with the matching CLI subcommand trees: `kasapi-cli domains list` and `domains get ` (`get_domains`, the latter passing a diff --git a/internal/cli/subdomains.go b/internal/cli/subdomains.go index 2a97b4d..3872e84 100644 --- a/internal/cli/subdomains.go +++ b/internal/cli/subdomains.go @@ -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 } @@ -38,3 +41,25 @@ func newSubdomainsListCmd(opts *RootOptions) *cobra.Command { }, } } + +func newSubdomainsGetCmd(opts *RootOptions) *cobra.Command { + return &cobra.Command{ + Use: "get ", + 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 + }, + } +} diff --git a/internal/subdomain/subdomain.go b/internal/subdomain/subdomain.go index 4db9c7e..13b7016 100644 --- a/internal/subdomain/subdomain.go +++ b/internal/subdomain/subdomain.go @@ -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"` @@ -60,7 +63,8 @@ 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 } @@ -68,8 +72,9 @@ type Client struct { // 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 { @@ -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) { @@ -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) +} diff --git a/internal/subdomain/subdomain_test.go b/internal/subdomain/subdomain_test.go index 321e9bb..4949f4a 100644 --- a/internal/subdomain/subdomain_test.go +++ b/internal/subdomain/subdomain_test.go @@ -104,6 +104,43 @@ 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") @@ -111,6 +148,32 @@ func TestClientPropagatesError(t *testing.T) { 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) {