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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,14 @@ when it is left off.
| `ssh` · `sql` · `db dump\|export\|import\|size` · `mount` | Getting inside. `db dump` writes a file named for the project and environment, all of it or `--table`s; `db size` shows the largest tables; `sql -- <query>` runs one query |
| `drush` | `drush` in the container, over the same forced command `ssh --` uses |
| `var list\|get\|set\|delete\|import\|apply` | Variables, at either scope; `import` adds a `.env`, `apply` restarts the site with them now |
| `domain list\|add\|verify\|delete` | Hostnames, and the DNS each one needs |
| `domain list\|add\|verify\|primary\|canonical\|delete` | Hostnames, the DNS each one needs, and which one the site redirects to |
| `service list` | The stack, and the versions that go in `vallic.yaml` |
| `backup list\|create\|download\|restore` | Copies, and putting one back |
| `env create` · `env source` · `env maintenance` · `env delete` | Raising, repointing, taking offline and removing an environment |
| `cdn show\|clear` · `metrics` | What the CDN caches, and emptying it; CPU, memory and disk over a window |
| `validate [--local]` | Checking `vallic.yaml` before you commit it |
| `init` | Writes a starting `vallic.yaml` from what the checkout looks like: Drupal, Laravel, WordPress, PHP, Node.js (or Bun), Go, Rust, Python, Django, Ruby and Rails |
| `link` | Records which project this checkout is, when the git remote cannot say |
| `link` | Records a project for directories with no git remote; a checkout whose remote names no project is refused |

Upsun's and Platform.sh's spelling works too: `vallic db:dump`, `vallic environment:list` and `vallic user:add` are `db dump`, `env list` and `team invite`.

Expand Down
9 changes: 7 additions & 2 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ vallic ssh-key add
vallic ssh-key add ~/.ssh/vallic.pub --label work-laptop
vallic ssh-key list

# Record a project for directories that are not a clone of its repository.
# One for the whole machine: a clone's own remote still answers first.
# Record a project for directories with no git remote. One for the whole
# machine, so a checkout never uses it: its remote answers, or it is refused.
vallic link acme-site

# What this checkout points at: project, environment, branch, last deploy
Expand Down Expand Up @@ -300,6 +300,11 @@ vallic domain add shop.example.com production
vallic domain verify shop.example.com production
vallic domain list production

# The site's address: the platform hostname redirects to it, other domains
# still serve. Canonical redirects every other hostname to it too
vallic domain primary shop.example.com production
vallic domain canonical shop.example.com production

# Give one up
vallic domain delete old.example.com production
```
Expand Down
57 changes: 57 additions & 0 deletions internal/api/domains_promote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)

// primary sends canonical false as well, so a canonical hostname made primary
// stops redirecting the others; canonical sends canonical alone, which makes
// it primary too.
func TestPromoteDomainSaysWhetherTheOthersRedirect(t *testing.T) {
for _, tc := range []struct {
name string
canonical bool
want map[string]any
}{
{"primary", false, map[string]any{"primary": true, "canonical": false}},
{"canonical", true, map[string]any{"canonical": true}},
} {
t.Run(tc.name, func(t *testing.T) {
var method, path string
var body map[string]any

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, path = r.Method, r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"domain":{"id":5,"hostname":"shop.example.com","is_primary":true},"message":"shop.example.com is now primary."}`))
}))
defer server.Close()

client := New(server.URL, anonymous{}, "test")

updated, err := client.PromoteDomain(context.Background(), 5, tc.canonical)
if err != nil {
t.Fatalf("PromoteDomain: %v", err)
}

if method != http.MethodPatch || path != Prefix+"/domains/5" {
t.Errorf("sent %s %s, want PATCH %s/domains/5", method, path, Prefix)
}

if !reflect.DeepEqual(body, tc.want) {
t.Errorf("body = %v, want %v", body, tc.want)
}

if !updated.Domain.IsPrimary || updated.Message == "" {
t.Errorf("answer = %+v, want the domain and the message", updated)
}
})
}
}
15 changes: 13 additions & 2 deletions internal/api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,8 +692,12 @@ type Domain struct {
// environment and cannot be removed on its own.
Kind string `json:"kind"`

IsPrimary bool `json:"is_primary"`
IsApex bool `json:"is_apex"`
// IsPrimary is the site's address, which the platform hostname redirects
// to; IsCanonical, only ever on a primary custom domain, redirects every
// other hostname to it as well.
IsPrimary bool `json:"is_primary"`
IsCanonical bool `json:"is_canonical"`
IsApex bool `json:"is_apex"`

// VerificationState is three values, not two: "unverified" means never
// checked, "failed" means checked and the record was wrong. A client with
Expand Down Expand Up @@ -763,6 +767,13 @@ type VerificationResult struct {
Domain Domain `json:"domain"`
}

// DomainUpdated is a domain after a change, and what the change did, in
// English, for printing.
type DomainUpdated struct {
Domain Domain `json:"domain"`
Message string `json:"message"`
}

// DomainRemoved is what a delete removed.
//
// A body rather than an empty 200, so a client prints what the control plane
Expand Down
27 changes: 27 additions & 0 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,33 @@ func (c *Client) VerifyDomain(ctx context.Context, domainID int) (*VerificationR
return &out, nil
}

// PromoteDomain makes a hostname the environment's primary address, and says
// whether every other hostname redirects to it.
//
// Canonical false is sent as well as primary true, rather than primary alone,
// so the one request means "primary and nothing more" whatever the hostname
// was: a canonical one stays primary and stops redirecting the others, which
// primary alone would leave as it is.
func (c *Client) PromoteDomain(ctx context.Context, domainID int, canonical bool) (*DomainUpdated, error) {
patch := map[string]any{"canonical": canonical}
if !canonical {
patch["primary"] = true
}

body, err := json.Marshal(patch)
if err != nil {
return nil, err
}

var out DomainUpdated

if err := c.do(ctx, http.MethodPatch, fmt.Sprintf("/domains/%d", domainID), body, &out); err != nil {
return nil, err
}

return &out, nil
}

// DeleteDomain removes a hostname.
func (c *Client) DeleteDomain(ctx context.Context, domainID int) (*DomainRemoved, error) {
var out DomainRemoved
Expand Down
87 changes: 86 additions & 1 deletion internal/cli/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ actually being routed.`,
domainListCommand(),
domainAddCommand(),
domainVerifyCommand(),
domainPrimaryCommand(),
domainCanonicalCommand(),
domainDeleteCommand(),
},
}
Expand Down Expand Up @@ -62,7 +64,7 @@ records the platform is looking for when it does not find them.`,
}

table := output.Table{
Columns: []string{"hostname", "kind", "state", "canonical", "tls"},
Columns: []string{"hostname", "kind", "state", "primary", "canonical", "tls"},
Empty: "No hostnames, which should not be possible.",
}

Expand All @@ -74,6 +76,7 @@ records the platform is looking for when it does not find them.`,
domain.Kind,
domainState(domain),
yesNo(domain.IsPrimary),
yesNo(domain.IsCanonical),
domain.TLSMode,
})
}
Expand Down Expand Up @@ -228,6 +231,88 @@ pass picks up a record that lands later anyway.`,
}
}

func domainPrimaryCommand() *Command {
return &Command{
Name: "primary",
Summary: "make a hostname the site's address",
Usage: "domain primary <hostname> [<env>]",
Long: `Makes it the address the site is: its base URL, and the one the
platform hostname redirects to, path and all. Your other hostnames go on
serving the site as they are; ` + "`domain canonical`" + ` redirects them too.

On a hostname that is canonical it stops the other hostnames redirecting, and
it stays primary.

Making the platform hostname primary again is how redirects are turned off:
nothing redirects then.

Only a verified hostname can be primary.`,
Run: func(ctx context.Context, env *Env, args []string) error {
return promoteDomain(ctx, env, args, false)
},
}
}

func domainCanonicalCommand() *Command {
return &Command{
Name: "canonical",
Summary: "make a hostname primary, and redirect every other one to it",
Usage: "domain canonical <hostname> [<env>]",
Long: `Makes it primary, and redirects every other hostname of the
environment to it, path and all — the platform hostname and your other
domains alike. ` + "`domain primary`" + ` on the same hostname undoes the redirects and
keeps it primary.

Only one of your own verified hostnames can be canonical. The platform
hostname cannot, because your own domains would redirect to it.`,
Run: func(ctx context.Context, env *Env, args []string) error {
return promoteDomain(ctx, env, args, true)
},
}
}

// promoteDomain is domain primary and domain canonical: the same request,
// with or without the other hostnames redirecting.
func promoteDomain(ctx context.Context, env *Env, args []string, canonical bool) error {
if len(args) == 0 {
return Usagef(nil, "name a hostname")
}

hostname := args[0]

target, err := env.ResolveEnvironment(ctx, firstOf(args[1:]))
if err != nil {
return err
}

client, err := env.Client()
if err != nil {
return err
}

domain, err := findDomain(ctx, client, target.Environment.ID, hostname)
if err != nil {
return err
}

updated, err := client.PromoteDomain(ctx, domain.ID, canonical)
if err != nil {
if api.Code(err) == "not_verified" {
return fmt.Errorf("%w\n vallic domain verify %s", err, domain.Hostname)
}

return err
}

if env.Printer.Structured() {
return env.Printer.Value(updated)
}

env.Printer.Good("%s", updated.Message)

return nil
}

func domainDeleteCommand() *Command {
var yes bool

Expand Down
Loading
Loading