diff --git a/README.md b/README.md index 8e7b93f..ff55134 100644 --- a/README.md +++ b/README.md @@ -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 -- ` 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`. diff --git a/USAGE.md b/USAGE.md index 85f31e5..d5ea17f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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 @@ -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 ``` diff --git a/internal/api/domains_promote_test.go b/internal/api/domains_promote_test.go new file mode 100644 index 0000000..85fbdfb --- /dev/null +++ b/internal/api/domains_promote_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/models.go b/internal/api/models.go index 4316afa..0acff64 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -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 @@ -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 diff --git a/internal/api/routes.go b/internal/api/routes.go index 849f919..f6cdb1e 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -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 diff --git a/internal/cli/domain.go b/internal/cli/domain.go index 0784741..f781485 100644 --- a/internal/cli/domain.go +++ b/internal/cli/domain.go @@ -26,6 +26,8 @@ actually being routed.`, domainListCommand(), domainAddCommand(), domainVerifyCommand(), + domainPrimaryCommand(), + domainCanonicalCommand(), domainDeleteCommand(), }, } @@ -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.", } @@ -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, }) } @@ -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 []", + 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 []", + 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 diff --git a/internal/cli/link.go b/internal/cli/link.go index e8167d2..9aafeee 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "flag" "fmt" "os" @@ -12,40 +13,41 @@ import ( "github.com/vallic/vallic-cli/internal/resolve" ) -// linkCommand records which project a checkout is. +// linkCommand records which project a directory with no git remote is. // -// For the one case internal/resolve cannot answer on its own: a checkout -// whose remote matches no project this credential can see, where the -// resolution stops and lists the candidates rather than picking one. A fork, -// a mirror, a repository added under a different host, or no git at all. +// For the one case internal/resolve cannot answer on its own: a directory +// that is not a checkout, or a checkout with no origin, where there is +// nothing to match a project against. // -// What it records is not a property of the checkout, and that is the whole +// What it records is not a property of the directory, and that is the whole // caveat. It goes in the configuration this machine shares between every -// directory, so the resolver reads it only *after* the git remote: a -// checkout that names its own project goes on resolving to it. Read first, -// a project linked once answered for every checkout on the machine, and a -// command run in one project's checkout acted on another project. +// directory, so the resolver never reads it where there is a git remote. Read +// before the remote, a project linked once answered for every checkout on the +// machine; read after a remote that matched nothing, it still answered for a +// checkout of a renamed repository. Both times a command run in one project's +// checkout acted on another project. func linkCommand() *Command { var forget bool return &Command{ Name: "link", - Summary: "record which project this checkout is", + Summary: "record which project a directory with no git remote is", Usage: "link [] [--clear]", Long: `Most checkouts need this and never run it. A project carries its repository's full name, so the git remote is matched against it and the project is worked out with nothing recorded anywhere. -This is for the checkouts where that fails, which is what "cannot tell which -project you mean" is saying: a fork, a mirror, a repository the project was -added under another name, or a directory that is not a checkout at all. +This is for the directories where there is no remote to match: one that is +not a checkout at all, or a checkout with no origin. -With no argument it says what is recorded now, or what commands resolve to -here without it. +A checkout whose remote names no project is refused rather than answered from +here: what is recorded is one project for the whole machine, and the checkout +is most likely another one's. Update the remote if the repository was renamed +or moved, or name the project with --project or VALLIC_PROJECT for a fork or +a mirror. -What it records is machine-wide, not per checkout, so it answers only where -a checkout cannot: a checkout whose git remote names a project you can see -goes on resolving to that project. --clear forgets it. +With no argument it says what is recorded now, or what commands resolve to +here without it. --clear forgets it. It records no environment, and that is deliberate. The environment comes from the branch, which changes while you work. An environment recorded once would @@ -107,10 +109,10 @@ VALLIC_ENVIRONMENT.`, return env.Printer.Value(map[string]any{"project": chosen}) } - env.Printer.Good("Commands act on %s where a checkout cannot say.", chosen.MachineName) + env.Printer.Good("Commands act on %s where there is no git remote to say.", chosen.MachineName) if dir, dirErr := config.Dir(); dirErr == nil { - env.Printer.Say(" recorded in %s, for the checkouts on this machine that cannot say", dir) + env.Printer.Say(" recorded in %s, for the directories on this machine with no git remote", dir) } warnOverridden(ctx, env, projects, chosen) @@ -128,7 +130,7 @@ VALLIC_ENVIRONMENT.`, // for a `$(…)` to capture, and which of the two it is goes to stderr. func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []api.Project) error { recorded := cfg.Project - inferred := inferProject(ctx, projects) + inferred, refused := inferProject(ctx, projects) if env.Printer.Structured() { name := "" @@ -157,12 +159,19 @@ func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []ap env.Printer.Say(" worked out from this checkout") if recorded != "" && recorded != inferred.MachineName { - env.Printer.Say(" %s is recorded too, and answers only where a checkout cannot say", recorded) + env.Printer.Say(" %s is recorded too, and answers only where there is no git remote", recorded) } return nil } + // A checkout whose remote names no project. Commands run here are refused + // and what is recorded does not answer, so printing it would be the wrong + // answer to "which project am I on". + if refused != nil { + return refused + } + if recorded == "" { if len(projects) == 0 { return fmt.Errorf("this credential can see no projects, so there is nothing to link to") @@ -181,7 +190,7 @@ func reportLink(ctx context.Context, env *Env, cfg *config.Config, projects []ap env.Printer.Line("%s", recorded) if dir, err := config.Dir(); err == nil { - env.Printer.Say(" recorded in %s, for the checkouts on this machine that cannot say", dir) + env.Printer.Say(" recorded in %s, for the directories on this machine with no git remote", dir) } return nil @@ -216,7 +225,7 @@ func clearLink(env *Env, cfg *config.Config) error { // command reads the same variable again. env.Printer.Good("The configuration file records no project now.") default: - env.Printer.Good("Forgot %s. A checkout that cannot say which project it is will be asked.", was) + env.Printer.Good("Forgot %s. A directory with no git remote will be asked which project it is.", was) } if fromEnv != "" { @@ -237,10 +246,15 @@ func warnOverridden(ctx context.Context, env *Env, projects []api.Project, chose env.Printer.Warn("VALLIC_PROJECT is set to %s, every command reads it first, and it has not changed", fromEnv) } - inferred := inferProject(ctx, projects) + inferred, refused := inferProject(ctx, projects) if inferred != nil && inferred.ID != chosen.ID { - env.Printer.Warn("this checkout goes on resolving to %s, which its git remote names; the link answers only where a checkout cannot say", inferred.MachineName) + env.Printer.Warn("this checkout goes on resolving to %s, which its git remote names; the link answers only where there is no git remote", inferred.MachineName) + } + + var ambiguous *resolve.Ambiguous + if errors.As(refused, &ambiguous) { + env.Printer.Warn("this checkout's remote is %s, which no project builds from, so the link does not answer here; update the remote, or name the project with --project or VALLIC_PROJECT", ambiguous.Remote) } } @@ -251,20 +265,28 @@ func warnOverridden(ctx context.Context, env *Env, projects []api.Project, chose // command's whole job, and a hint would answer with what was recorded a // moment ago. // -// A failure is not one: it means the checkout cannot say, which is the state -// this command is for. -func inferProject(ctx context.Context, projects []api.Project) *api.Project { +// A failure is mostly not one: it means there is no remote to say, which is +// the state this command is for. The exception comes back as the error: a +// checkout whose remote names no project, which commands refuse and which +// nothing recorded answers for. +func inferProject(ctx context.Context, projects []api.Project) (*api.Project, error) { dir, err := os.Getwd() if err != nil { - return nil + return nil, nil } project, err := (&resolve.Resolver{Client: known(projects), Dir: dir}).Project(ctx) + + var ambiguous *resolve.Ambiguous + if errors.As(err, &ambiguous) && ambiguous.Remote != "" { + return nil, err + } + if err != nil { - return nil + return nil, nil } - return project + return project, nil } // known answers a resolver out of the list this command already read, so diff --git a/internal/cli/target.go b/internal/cli/target.go index 12d7175..feff72e 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -37,9 +37,9 @@ func (t *Target) Named() string { // // The precedence is settled here, once, for both nouns: a flag beats the // environment variable, and both beat the checkout. The project `vallic -// link` recorded comes after the checkout, not before it — it is one answer -// for every directory on the machine, and a checkout that names its own -// project is the better one. See internal/resolve for the rules below that. +// link` recorded answers only where there is no git remote — it is one +// answer for every directory on the machine, and a checkout answers for +// itself or not at all. See internal/resolve for the rules below that. func (e *Env) Resolver(client resolve.Client) (*resolve.Resolver, error) { cfg, err := e.Config() if err != nil { diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 66b150f..a531ac4 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -53,6 +53,11 @@ type Ambiguous struct { // Because is why the CLI could not decide, in a clause. Because string + + // Remote is the checkout's repository, as `owner/name`, when it names one + // no project builds from. `vallic link` cannot answer in a checkout, so + // it is not offered there; the remote is what needs fixing. + Remote string } func (e *Ambiguous) Error() string { @@ -79,9 +84,13 @@ func (e *Ambiguous) Error() string { // Only for a project, because `link` records only a project. Offering it // for an environment would point somebody at a command that cannot fix - // what they just hit. - if e.What == "project" { - fmt.Fprint(&b, "\n or record it for this checkout: vallic link ") + // what they just hit — and the same goes for a checkout with a remote, + // where the link never answers. + switch { + case e.Remote != "": + fmt.Fprint(&b, "\n or, if the repository was renamed or moved: git remote set-url origin ") + case e.What == "project": + fmt.Fprint(&b, "\n or record it for this directory: vallic link ") } return b.String() @@ -127,9 +136,10 @@ type Resolver struct { // ProjectRecorded is what `vallic link` wrote in the configuration. It // answers for every directory on the machine, so it is asked only where - // the checkout cannot answer: read before the git remote, a project + // there is no git remote at all: read before the git remote, a project // linked for one checkout sent `vallic drush` and `vallic deploy` run in - // another to the first one's production. + // another to the first one's production, and read for a remote that + // matched nothing, it did the same to a checkout of a renamed repository. ProjectRecorded string } @@ -153,10 +163,26 @@ func (r *Resolver) Project(ctx context.Context) (*api.Project, error) { if match := projectByRepository(projects, remote); match != nil { return match, nil } + + // A checkout that names a repository no project builds from is not + // answered by anything below. What was linked and "the only project" + // are both answers for some other directory: taken here, a dump run + // in one project's checkout, whose remote still had the repository's + // old name, came back with another project's production database. + name := normaliseRemote(remote) + if name == "" { + name = remote + } + + return nil, &Ambiguous{ + What: "project", + Candidates: projectNames(projects), + Because: fmt.Sprintf("this checkout's remote is %s, and no project you can see builds from it", name), + Remote: name, + } } - // What was linked, for a checkout whose remote names no project here: a - // fork, a mirror, a directory with no git at all. + // What was linked, for a directory with no git remote to say. if r.ProjectRecorded != "" { return matchProject(projects, r.ProjectRecorded) } diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index b105a3e..29f4fa4 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -230,9 +230,8 @@ func TestACheckoutBeatsTheRecordedProject(t *testing.T) { } } -// The linked project answers where the checkout cannot: no git, or a remote -// naming no project this credential can see. -func TestTheRecordedProjectAnswersWhereTheCheckoutCannot(t *testing.T) { +// The linked project answers where there is no git remote to say. +func TestTheRecordedProjectAnswersWhereThereIsNoRemote(t *testing.T) { client := &fakeClient{ projects: []api.Project{ {ID: 1, MachineName: "blog", Repository: repo("acme/blog")}, @@ -240,17 +239,69 @@ func TestTheRecordedProjectAnswersWhereTheCheckoutCannot(t *testing.T) { }, } - for name, dir := range map[string]string{ - "no git": t.TempDir(), - "a fork's remote": checkout(t, "git@github.com:someone/shop-fork.git"), + got, err := (&Resolver{Client: client, Dir: t.TempDir(), ProjectRecorded: "blog"}).Project(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.MachineName != "blog" { + t.Errorf("resolved %s, want the linked blog", got.MachineName) + } +} + +// A checkout whose remote names no project is refused, whatever is linked +// and however few projects there are. +// +// Both are answers for some other directory. Taking the link here sent a +// database dump, run in a checkout whose remote still had its repository's +// old name, to another project's production. +func TestAnUnmatchedRemoteIsRefused(t *testing.T) { + for name, tc := range map[string]struct { + projects []api.Project + recorded string + }{ + "with a project linked": { + projects: []api.Project{ + {ID: 1, MachineName: "blog", Repository: repo("acme/blog")}, + {ID: 2, MachineName: "webshop", Repository: repo("acme/shop")}, + }, + recorded: "blog", + }, + "with only one project": { + projects: []api.Project{{ID: 1, MachineName: "blog", Repository: repo("acme/blog")}}, + }, } { - got, err := (&Resolver{Client: client, Dir: dir, ProjectRecorded: "blog"}).Project(context.Background()) - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if got.MachineName != "blog" { - t.Errorf("%s: resolved %s, want the linked blog", name, got.MachineName) - } + t.Run(name, func(t *testing.T) { + r := &Resolver{ + Client: &fakeClient{projects: tc.projects}, + Dir: checkout(t, "git@github.com:acme/old-name.git"), + ProjectRecorded: tc.recorded, + } + + got, err := r.Project(context.Background()) + + var ambiguous *Ambiguous + if !errors.As(err, &ambiguous) { + t.Fatalf("resolved %v, err %v; want an *Ambiguous", got, err) + } + + if ambiguous.Remote != "acme/old-name" { + t.Errorf("Remote = %q, want acme/old-name", ambiguous.Remote) + } + + msg := err.Error() + if !strings.Contains(msg, "acme/old-name") { + t.Errorf("the error does not name the remote:\n%s", msg) + } + if !strings.Contains(msg, "git remote set-url") { + t.Errorf("the error does not say how to fix the remote:\n%s", msg) + } + + // The link cannot answer in a checkout, so offering it would send + // somebody to a command that changes nothing here. + if strings.Contains(msg, "vallic link") { + t.Errorf("the error offers vallic link, which does not answer in a checkout:\n%s", msg) + } + }) } }