diff --git a/README.md b/README.md index 91a2127..fe44f41 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,27 @@ $ vallic mount download ./files ## Installing +```bash +curl -fsSL https://raw.githubusercontent.com/Vallic/vallic-cli/main/installer.sh | bash +``` + +Linux and macOS, amd64 and arm64. Needs `curl` and one of `sha256sum` or +`shasum`, and nothing else — an installer that pulls in a package manager first +is not a one-line installer. It lands in `/usr/local/bin` where that is +writable and `~/.local/bin` otherwise; `VALLIC_INSTALL_DIR` overrides both. + +**It asks the control plane which version to install, not GitHub**, and that is +the point rather than an implementation detail. GitHub holds the bytes and the +control plane holds their SHA-256, so they are two systems an attacker would +both have to hold: a digest served by whoever served the binary can only say +the download arrived intact. Nothing is installed unless the checksum matches, +and the control plane also decides *which* version a customer should run, so a +tag that exists but was thought better of is not something this will hand you. +`VALLIC_API` points it at another control plane. Its own version is `--version` +away and `vallic self-update` reads the same endpoint. + +Or with Go: + ```bash go install github.com/vallic/vallic-cli/cmd/vallic@latest ``` @@ -199,7 +220,7 @@ when it is left off. | `logs` | Where the site's own request and error logs go — the project's destination and the machine's copy. Not a tail: Vallic Cloud does not keep them | | `ssh` · `sql` · `db` · `mount` | Getting inside | | `drush` | `drush` in the container, over the same forced command `ssh --` uses | -| `var list\|get\|set\|delete` | Variables, at either scope | +| `var list\|get\|set\|delete\|apply` | Variables, at either scope; `apply` restarts the site with them now | | `domain list\|add\|verify\|delete` | Hostnames, and the DNS each one needs | | `service list` | The stack, and the versions that go in `vallic.yaml` | | `backup list\|create\|download\|restore` | Copies, and putting one back | diff --git a/installer.sh b/installer.sh new file mode 100755 index 0000000..15ec3ba --- /dev/null +++ b/installer.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# +# Installs the vallic CLI. +# +# curl -fsSL https://raw.githubusercontent.com/Vallic/vallic-cli/main/installer.sh | bash +# +# What it needs: curl, and either sha256sum or shasum. Nothing else — no jq, no +# python, no unzip. A one-line installer that pulls in a package manager first +# is not a one-line installer. +# +# ## Where the version comes from, and why it is not GitHub +# +# The control plane is asked, not the releases API: `GET /api/vc/v1/cli/latest` +# answers the version it publishes and the SHA-256 of each build, to anybody, +# with no credential. Two reasons that is the right source. +# +# The checksum is one. GitHub holds the bytes, the control plane holds the +# digest, and they are different systems on purpose: a digest served by whoever +# served the binary can only say the download arrived intact, and says nothing +# about a release that was replaced at the source. An installer that trusted +# GitHub's own checksum would be verifying nothing an attacker could not also +# have written. +# +# The other is that the control plane decides what a customer should run. A tag +# can exist on GitHub — a release candidate, a build cut and then thought +# better of — without being the version anybody should install. `self-update` +# reads exactly this endpoint, so the binary this installs and the binary it +# upgrades itself to are chosen by the same authority. +# +# Override the control plane with VALLIC_API, which is what a test estate wants. +set -euo pipefail + +API="${VALLIC_API:-https://console.vallic.com}" +BINARY=vallic + +# Said on stderr, so a script that captures stdout gets nothing but the path. +say() { printf '%s\n' "$*" >&2; } +die() { printf 'installer: %s\n' "$*" >&2; exit 1; } + +need() { command -v "$1" >/dev/null 2>&1 || die "this needs $1 on PATH."; } + +# Go's own spelling of both halves, because that is how the manifest is keyed +# and how the release assets are named. uname says something different for +# every one of them. +platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) die "no build for $os. Linux and macOS only; see the README for why Windows is absent." ;; + esac + + case "$arch" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) die "no build for $arch." ;; + esac + + printf '%s/%s' "$os" "$arch" +} + +# The manifest, with PHP's escaped slashes turned back into slashes. +# +# json_encode escapes every forward slash by default, so the real response says +# "linux\/amd64" and "https:\/\/github.com". Unescaping once here means the +# extraction below can be written against the shape a person would expect, +# rather than every pattern carrying backslashes nobody would guess at. +manifest() { + curl -fsSL --max-time 30 "$API/api/vc/v1/cli/latest" | sed 's#\\/#/#g' +} + +# One field out of one platform's entry. +# +# Deliberately narrow: it matches the object under this exact platform key and +# reads one string from it. A general JSON parser in sed is a bad idea and this +# is not one — if the shape changes, this stops finding anything and the +# installer says so rather than installing something unverified. +field() { + local json="$1" platform="$2" key="$3" entry + entry="$(printf '%s' "$json" | sed -n "s|.*\"$platform\":{\([^}]*\)}.*|\1|p")" + + printf '%s' "$entry" | sed -n "s|.*\"$key\":\"\([^\"]*\)\".*|\1|p" +} + +# Whether a package manager owns the file this is about to write. +# +# The question is about the *destination*, not about whatever happens to be +# first on PATH. Writing over Homebrew's or a snap's copy leaves a package whose +# manifest no longer describes what is on disk, and the answer there is to use +# that tool. A copy somewhere else on PATH is a different matter entirely and is +# only worth mentioning — see shadowed(). +# +# `/usr/bin` is deliberately *not* here. It is where a distribution's packages +# land and also where people put binaries by hand, this cannot tell the two +# apart without shelling out to rpm or dpkg, and refusing on the guess is how +# an installer tells somebody to use a package manager that never installed it. +# Overwriting a hand-placed file is what they asked for; overwriting a packaged +# one is refused above by path, where the path is actually distinctive. +owned_by_package() { + local target="$1" + + # Resolved, because Homebrew's bin is links into the Cellar. + if command -v readlink >/dev/null 2>&1; then + target="$(readlink -f "$target" 2>/dev/null || printf '%s' "$target")" + fi + + case "$target" in + */Cellar/* | /opt/homebrew/* | /home/linuxbrew/.linuxbrew/*) + say "$target belongs to Homebrew." + say "Upgrade it with: brew upgrade vallic" + return 0 + ;; + /snap/* | /var/lib/snapd/*) + say "$target belongs to snap." + say "Upgrade it with: snap refresh vallic" + return 0 + ;; + /nix/store/*) + say "$target belongs to Nix, which does not permit writing over it." + return 0 + ;; + esac + + return 1 +} + +# Another copy earlier on PATH than the one just installed. +# +# Said rather than refused. Somebody with an old build in /usr/bin and a new one +# in ~/.local/bin will run the old one and have no idea why the version they +# just installed is not the version they get, and that is a worse outcome than +# a line of output nobody needed. +shadowed() { + local installed="$1" first + first="$(command -v "$BINARY" 2>/dev/null || true)" + + [ -n "$first" ] && [ "$first" != "$installed" ] +} + +# Where to put it: asked for, or the first writable of the usual two. +# +# /usr/local/bin is on PATH almost everywhere and needs root almost nowhere on +# a developer's machine. ~/.local/bin is the fallback rather than the default +# because it is on PATH by default on fewer systems, and an installer whose +# binary is not on PATH has not finished. +install_dir() { + if [ -n "${VALLIC_INSTALL_DIR:-}" ]; then + printf '%s' "$VALLIC_INSTALL_DIR" + return + fi + + if [ -w /usr/local/bin ] 2>/dev/null; then + printf '/usr/local/bin' + return + fi + + printf '%s/.local/bin' "${HOME}" +} + +main() { + need curl + need uname + + local sha_tool + if command -v sha256sum >/dev/null 2>&1; then + sha_tool=sha256sum + elif command -v shasum >/dev/null 2>&1; then + sha_tool="shasum -a 256" + else + die "this needs sha256sum or shasum to verify the download." + fi + + local key json version url want + key="$(platform)" + + json="$(manifest)" || die "could not reach $API. Set VALLIC_API if your control plane is elsewhere." + + case "$json" in + *'"published":true'*) ;; + *) die "$API publishes no version of this binary yet." ;; + esac + + version="$(printf '%s' "$json" | sed -n 's|.*"version":"\([^"]*\)".*|\1|p')" + url="$(field "$json" "$key" url)" + want="$(field "$json" "$key" sha256)" + + [ -n "$version" ] || die "$API did not name a version." + + # Both halves or neither. A URL with no checksum is a binary nobody + # verified, which is the one thing this script exists to prevent. + if [ -z "$url" ] || [ -z "$want" ]; then + die "$API publishes no verifiable build for $key." + fi + + local dir tmp + dir="$(install_dir)" + + # Asked before anything is downloaded: a refusal that first spends somebody's + # bandwidth is a slower way to say the same thing. + if [ -e "$dir/$BINARY" ] && owned_by_package "$dir/$BINARY"; then + exit 0 + fi + + mkdir -p "$dir" || die "cannot create $dir." + [ -w "$dir" ] || die "cannot write to $dir. Re-run with sudo, or set VALLIC_INSTALL_DIR." + + # In the target directory, so the move at the end is a rename within one + # filesystem and therefore atomic. A download to /tmp and a copy across + # devices can leave half a binary at the destination. + tmp="$(mktemp "$dir/.$BINARY.XXXXXX")" || die "cannot write a temporary file in $dir." + trap 'rm -f "$tmp"' EXIT + + say "Downloading $BINARY $version for $key." + curl -fsSL --max-time 300 -o "$tmp" "$url" || die "could not download $url" + + local got + got="$($sha_tool "$tmp" | cut -d' ' -f1)" + + if [ "$got" != "$want" ]; then + die "checksum mismatch: $API expects $want, the download is $got. Nothing was installed." + fi + + chmod 0755 "$tmp" + mv -f "$tmp" "$dir/$BINARY" + trap - EXIT + + say "Installed $dir/$BINARY" + + case ":${PATH}:" in + *":$dir:"*) + if shadowed "$dir/$BINARY"; then + say "" + say "Note: $(command -v "$BINARY") comes first on your PATH, so that is what runs." + say "Remove it, or put $dir earlier." + else + say "Run: $BINARY login" + fi + ;; + *) + say "" + say "$dir is not on your PATH. Add it:" + say " export PATH=\"$dir:\$PATH\"" + ;; + esac +} + +main "$@" diff --git a/internal/api/client.go b/internal/api/client.go index b90c5d9..39fdc57 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -108,6 +108,12 @@ const ( // CodeUnauthorized is a credential the control plane does not accept. CodeUnauthorized = "unauthorized" + // CodeNotBuildable is a branch that cannot be built right now: no such + // branch on the host, a repository host that cannot be reached, a project + // with no build machine yet. 409 rather than 400 because nothing about the + // request was wrong and a retry once the cause is fixed is exactly right. + CodeNotBuildable = "not_buildable" + // CodeRateLimited is one token asking too often -- 600 requests a minute, // counted per token rather than per person, so a runaway CI job does not // lock its team out of the console. The message says to wait and repeat diff --git a/internal/api/models.go b/internal/api/models.go index 140a480..cdfbbb2 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -1060,6 +1060,32 @@ func (m *StringMap) UnmarshalJSON(data []byte) error { return nil } +// Build is a build the control plane started. +// +// Answered by a 202: a build is minutes of somebody else's machine, so what +// comes back is what was started rather than what it produced. +type Build struct { + // Release is the entity id, which is what the platform's own logs name. + Release int `json:"release"` + + // ReleaseNumber is the project's own sequence -- what `--release` takes and + // what to print. Nil against a control plane that could not read it back. + ReleaseNumber *int `json:"release_number"` + + Environment string `json:"environment"` + GitRef string `json:"git_ref"` + + // WillDeploy is what happens when the build finishes, not what was asked + // for: an environment that watches this branch deploys the result whether + // or not anybody asked, because that is what auto-deploy means. + WillDeploy bool `json:"will_deploy"` + + // DeployRequested is whether this request asked for it, so the difference + // between "because you said so" and "because this environment always does" + // can be said out loud. + DeployRequested bool `json:"deploy_requested"` +} + // CronJob is one scheduled command. type CronJob struct { Name string `json:"name"` diff --git a/internal/api/routes.go b/internal/api/routes.go index 4f68607..77e5759 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -159,6 +159,25 @@ func (c *Client) Releases(ctx context.Context, projectID int, limit int, branch return out.Releases, nil } +// Build builds what the environment's branch points at now. +// +// deploy asks for the result to be deployed here when it finishes. Sent always +// rather than only when true, because the control plane's default and this +// one's are the same and a body that says which is a body that cannot drift. +func (c *Client) Build(ctx context.Context, environmentID int, deploy bool) (*Build, error) { + var out struct { + Build Build `json:"build"` + } + + body := map[string]any{"deploy": deploy} + + if err := c.post(ctx, fmt.Sprintf("/environments/%d/build", environmentID), body, &out); err != nil { + return nil, err + } + + return &out.Build, nil +} + // Rollback puts the release the current one replaced back. func (c *Client) Rollback(ctx context.Context, environmentID int) (*Deployment, error) { return c.queueDeployment(ctx, fmt.Sprintf("/environments/%d/rollback", environmentID)) @@ -204,17 +223,15 @@ func (c *Client) Deploy(ctx context.Context, environmentID int, release int) (*D } // EnvironmentVariables lists everything in effect for an environment, -// inherited definitions included. -func (c *Client) EnvironmentVariables(ctx context.Context, environmentID int) ([]Variable, error) { - var out struct { - Variables []Variable `json:"variables"` - } +// inherited definitions included, and whether the site has it yet. +func (c *Client) EnvironmentVariables(ctx context.Context, environmentID int) (*VariableList, error) { + var out VariableList if err := c.get(ctx, fmt.Sprintf("/environments/%d/variables", environmentID), &out); err != nil { return nil, err } - return out.Variables, nil + return &out, nil } // ProjectVariables lists what a project defines, before any environment diff --git a/internal/api/variables_apply.go b/internal/api/variables_apply.go new file mode 100644 index 0000000..97941b4 --- /dev/null +++ b/internal/api/variables_apply.go @@ -0,0 +1,51 @@ +package api + +import ( + "context" + "fmt" +) + +// VariableList is what an environment runs with, and whether it has it yet. +// +// Pending is the control plane's answer to "I set it and nothing changed": +// the values were saved but the site has not been restarted with them, which +// happens on the next deploy or on `var apply`. A pointer for the reason +// Variable.Description is one: a control plane older than the field does not +// send it, and nil ("cannot say") is a different answer from false ("nothing +// pending"), which a script reading --format json should be able to tell. +type VariableList struct { + Pending *bool `json:"pending"` + Variables []Variable `json:"variables"` +} + +// VariablesApplied is the answer to `var apply`. +// +// Environments by slug. Applied are restarting with their variables; +// unreachable could not be sent them just now, and go out with their next +// deploy instead. +type VariablesApplied struct { + Applied []string `json:"applied"` + Unreachable []string `json:"unreachable"` +} + +// ApplyEnvironmentVariables restarts an environment's site with its variables +// now, rather than with the next deploy. +func (c *Client) ApplyEnvironmentVariables(ctx context.Context, environmentID int) (*VariablesApplied, error) { + return c.applyVariables(ctx, fmt.Sprintf("/environments/%d/variables/apply", environmentID)) +} + +// ApplyProjectVariables does the same for every environment of a project +// still running on values the project has since changed. +func (c *Client) ApplyProjectVariables(ctx context.Context, projectID int) (*VariablesApplied, error) { + return c.applyVariables(ctx, fmt.Sprintf("/projects/%d/variables/apply", projectID)) +} + +func (c *Client) applyVariables(ctx context.Context, path string) (*VariablesApplied, error) { + var out VariablesApplied + + if err := c.post(ctx, path, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/internal/api/variables_apply_test.go b/internal/api/variables_apply_test.go new file mode 100644 index 0000000..f439f64 --- /dev/null +++ b/internal/api/variables_apply_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// apply is a POST to the scope's own route, and the answer names what +// restarted and what could not be reached. +func TestApplyPostsToTheScopesRoute(t *testing.T) { + var method, path string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"applied":["acme-staging"],"unreachable":["acme-production"]}`)) + })) + defer server.Close() + + client := New(server.URL, anonymous{}, "test") + + result, err := client.ApplyProjectVariables(context.Background(), 7) + if err != nil { + t.Fatalf("ApplyProjectVariables: %v", err) + } + + if method != http.MethodPost || path != Prefix+"/projects/7/variables/apply" { + t.Errorf("request = %s %s, want POST %s/projects/7/variables/apply", method, path, Prefix) + } + + if len(result.Applied) != 1 || result.Applied[0] != "acme-staging" { + t.Errorf("applied = %v", result.Applied) + } + + if len(result.Unreachable) != 1 || result.Unreachable[0] != "acme-production" { + t.Errorf("unreachable = %v", result.Unreachable) + } + + if _, err := client.ApplyEnvironmentVariables(context.Background(), 12); err != nil { + t.Fatalf("ApplyEnvironmentVariables: %v", err) + } + + if path != Prefix+"/environments/12/variables/apply" { + t.Errorf("path = %s, want %s/environments/12/variables/apply", path, Prefix) + } +} + +// A control plane that says nothing about pending is not one that says +// "nothing pending". The three answers have to stay three. +func TestPendingTellsUnknownFromFalse(t *testing.T) { + cases := []struct { + name string + body string + want *bool + }{ + {name: "an older control plane says nothing", body: `{"variables":[]}`, want: nil}, + {name: "up to date", body: `{"pending":false,"variables":[]}`, want: ptr(false)}, + {name: "behind", body: `{"pending":true,"variables":[]}`, want: ptr(true)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + list, err := New(server.URL, anonymous{}, "test").EnvironmentVariables(context.Background(), 1) + if err != nil { + t.Fatalf("EnvironmentVariables: %v", err) + } + + switch { + case tc.want == nil && list.Pending != nil: + t.Errorf("pending = %v, want nil", *list.Pending) + case tc.want != nil && (list.Pending == nil || *list.Pending != *tc.want): + t.Errorf("pending = %v, want %v", list.Pending, *tc.want) + } + }) + } +} diff --git a/internal/cli/build.go b/internal/cli/build.go new file mode 100644 index 0000000..6d89196 --- /dev/null +++ b/internal/cli/build.go @@ -0,0 +1,131 @@ +package cli + +import ( + "context" + "flag" + "fmt" + + "github.com/vallic/vallic-cli/internal/api" +) + +// buildCommand builds what an environment's branch points at now. +// +// The gap this fills is the one a pipeline hits first. A build otherwise comes +// from a push, so "deploy the current branch" meant making a commit nobody +// wanted, and `vallic deploy` could only ever ship a build that already +// existed -- which is why it answers `no_release` when run seconds after a +// push, before the build it is waiting for has finished. +// +// The commit is resolved by the control plane, not here. A release names one +// commit, which is what makes "deploy release 47" mean a set of bytes rather +// than a moment in time; resolving the branch on the builder would record a sha +// nobody chose, minutes after the person who asked looked at what was on it. +func buildCommand() *Command { + var deploy bool + + return &Command{ + Name: "build", + Summary: "build what an environment's branch points at now", + Usage: "build [] [--deploy]", + Long: `Builds the current commit of the branch this environment tracks, and +returns the release it opened. It does not wait: a build is minutes of a build +machine, so follow it with ` + "`vallic activity list`" + ` or watch for the +release in ` + "`vallic release list`" + `. + +Building and deploying are separate on purpose. A build produces a release; +deploying one is a second decision, and keeping them apart is what lets you +build a branch, look at what came out, and ship it -- or not. + +--deploy asks for the result to be deployed here when the build finishes, which +is the shape a pipeline wants: one command, one outcome. + +It is not a promise that nothing is deployed without it. An environment set to +deploy this branch automatically deploys the build either way, because that is +what auto-deploy means, and this command is not a way around a rule the project +set. The output says which it is going to be.`, + Flags: func(fs *flag.FlagSet) { + fs.BoolVar(&deploy, "deploy", false, "deploy the result here when the build finishes") + }, + Run: func(ctx context.Context, env *Env, args []string) error { + target, err := env.ResolveEnvironment(ctx, first(args)) + if err != nil { + return err + } + + client, err := env.Client() + if err != nil { + return err + } + + built, err := client.Build(ctx, target.Environment.ID, deploy) + if err != nil { + return describeBuildFailure(err) + } + + if env.Printer.Structured() { + return env.Printer.Value(map[string]any{"build": built}) + } + + env.Printer.Good("%s", buildStarted(built)) + + for _, line := range buildLines(built) { + env.Printer.Say("%s", line) + } + + return nil + }, + } +} + +// buildStarted is the one sentence worth reading. +// +// Named by its number rather than its id, because the number is what +// `--release` takes. Where the control plane could not read one back the id is +// printed and said to be one, rather than passed off as something typeable. +func buildStarted(built *api.Build) string { + if built.ReleaseNumber != nil { + return fmt.Sprintf("Building %s as release %d.", built.GitRef, *built.ReleaseNumber) + } + + return fmt.Sprintf("Building %s (release id %d).", built.GitRef, built.Release) +} + +// buildLines says what happens when it finishes, and how to watch. +func buildLines(built *api.Build) []string { + lines := make([]string, 0, 3) + + switch { + case built.DeployRequested: + lines = append(lines, fmt.Sprintf(" it will deploy to %s when the build finishes", built.Environment)) + case built.WillDeploy: + // Asked for nothing and getting a deployment anyway. The one case that + // has to be said out loud: somebody who ran this to look before + // shipping is about to ship. + lines = append(lines, fmt.Sprintf( + " %s deploys this branch automatically, so it will deploy there when the build finishes", + built.Environment, + )) + default: + lines = append(lines, + " nothing is deployed; it becomes a release you can deploy", + fmt.Sprintf(" ship it with: vallic deploy --environment %s", built.Environment), + ) + } + + return append(lines, " watch it with: vallic activity list") +} + +// describeBuildFailure adds the sentence that makes a refusal actionable. +// +// The control plane's own message is kept in every case: each of its refusals +// names something specific -- a branch that is not there, a repository host +// that is down, a project whose build machine is still being provisioned -- and +// a client that replaced those with one generic failure would be throwing away +// the only useful half. +func describeBuildFailure(err error) error { + if api.Code(err) == api.CodeNotBuildable { + return fmt.Errorf("%w\n `vallic env info` shows which branch this environment tracks", err) + } + + return err +} diff --git a/internal/cli/build_test.go b/internal/cli/build_test.go new file mode 100644 index 0000000..9122c0c --- /dev/null +++ b/internal/cli/build_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/vallic/vallic-cli/internal/api" +) + +// What happens when the build finishes has to be said, and said accurately. +// +// Three outcomes and they are genuinely different to the person reading. The +// middle one is why `will_deploy` is a separate field from `deploy_requested`: +// somebody who ran `vallic build` to look at a branch before shipping it, on an +// environment that deploys that branch automatically, is about to ship it. A +// client that echoed the request back would tell them nothing is being +// deployed while a deployment starts. +func TestTheBuildOutputSaysWhatWillActuallyHappen(t *testing.T) { + for _, tc := range []struct { + name string + built api.Build + want string + avoid string + }{ + { + name: "asked for", + built: api.Build{Environment: "acme-staging", WillDeploy: true, DeployRequested: true}, + want: "it will deploy to acme-staging", + }, + { + name: "not asked for, but auto-deploy will", + built: api.Build{Environment: "acme-staging", WillDeploy: true, DeployRequested: false}, + want: "deploys this branch automatically", + avoid: "nothing is deployed", + }, + { + name: "nothing will", + built: api.Build{Environment: "acme-staging", WillDeploy: false, DeployRequested: false}, + want: "nothing is deployed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + lines := strings.Join(buildLines(&tc.built), "\n") + + if !strings.Contains(lines, tc.want) { + t.Errorf("lines = %q, want them to say %q", lines, tc.want) + } + + if tc.avoid != "" && strings.Contains(lines, tc.avoid) { + t.Errorf("lines = %q, want them NOT to say %q", lines, tc.avoid) + } + }) + } +} + +// The release is named by its number, because the number is what --release +// takes. +// +// The id is global and the number is the project's own sequence. Printing 3339 +// for what the project calls 7 offers a figure that looks typeable and is not — +// a mistake already made twice on this surface, in `status` and in the agent's +// deploy log, so the route sends both and this prints the right one. +func TestTheBuildOutputNamesTheReleaseByItsNumber(t *testing.T) { + number := 7 + + got := buildStarted(&api.Build{Release: 3339, ReleaseNumber: &number, GitRef: "staging"}) + + if !strings.Contains(got, "release 7") { + t.Errorf("got %q, want it to name release 7", got) + } + + if strings.Contains(got, "3339") { + t.Errorf("got %q, want the id kept out of it", got) + } + + // And where the control plane could not read one back, the id is printed + // and labelled as one rather than passed off as typeable. + bare := buildStarted(&api.Build{Release: 3339, GitRef: "staging"}) + + if !strings.Contains(bare, "release id 3339") { + t.Errorf("got %q, want the id said to be an id", bare) + } +} diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..ce3a9d6 --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,330 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "sort" + "strings" +) + +// completionCommand writes a shell script that completes commands and flags. +// +// Static, and generated from the real tree rather than written by hand: the +// tree is walked at run time and the script it emits carries what it found, so +// a command added without a thought for completion is completed anyway. A +// hand-written list is a list that is wrong by the second release. +// +// **It completes nothing that needs the network.** Environment and project +// names would be the obvious next step and are deliberately absent: a TAB that +// makes an authenticated API call is a TAB that can be slow, can fail while +// somebody is mid-word, and can spend a token's request allowance -- the +// control plane allows one 600 a minute, and a person leaning on TAB is a +// plausible way to reach that. Completion is a text transformation on things +// this binary already knows. +func completionCommand() *Command { + return &Command{ + Name: "completion", + Summary: "write a shell completion script", + Usage: "completion bash|zsh|fish", + Long: `Completes command names, subcommands and flags. Writes the script to +stdout; where it goes from there is your shell's business: + + # bash — this session + source <(vallic completion bash) + # bash — every session + vallic completion bash > ~/.local/share/bash-completion/completions/vallic + + # zsh, somewhere on $fpath + vallic completion zsh > "${fpath[1]}/_vallic" + + # fish + vallic completion fish > ~/.config/fish/completions/vallic.fish + +It completes no names that live on the platform — not environments, not +projects. Those would need a request per keystroke against a control plane that +limits one token to six hundred a minute, so a person holding TAB could spend +an allowance their pipeline then needs. What it knows, it knows offline.`, + Run: func(ctx context.Context, env *Env, args []string) error { + if len(args) != 1 { + return fmt.Errorf("name a shell: bash, zsh or fish") + } + + tree := describeTree(Root(), env) + + switch args[0] { + case "bash": + fmt.Fprint(env.Out, bashCompletion(tree)) + case "zsh": + fmt.Fprint(env.Out, zshCompletion(tree)) + case "fish": + fmt.Fprint(env.Out, fishCompletion(tree)) + default: + return fmt.Errorf("no completion for %q: bash, zsh or fish", args[0]) + } + + return nil + }, + } +} + +// completionNode is one command, flattened for a shell to read. +type completionNode struct { + // Path is the words that reach it, without the binary name: "env", then + // "env list". Empty for the root. + Path []string + + // Subcommands are the names that may follow, sorted. + Subcommands []string + + // Flags are the long flag names it accepts, sorted, without dashes and + // including the globals every command takes. + Flags []string +} + +// describeTree flattens the command tree into what a shell script needs. +// +// Flags are collected by handing each command's own registrar a throwaway +// FlagSet and reading back what it registered, so this cannot disagree with +// what the command actually parses. +func describeTree(root *Command, env *Env) []completionNode { + var nodes []completionNode + + var walk func(cmd *Command, path []string) + + walk = func(cmd *Command, path []string) { + nodes = append(nodes, completionNode{ + Path: path, + Subcommands: childNames(cmd), + Flags: flagNames(cmd, env), + }) + + for _, child := range cmd.Children { + walk(child, append(append([]string{}, path...), child.Name)) + } + } + + walk(root, nil) + + return nodes +} + +// childNames is a command's subcommands, aliases included, sorted. +// +// Aliases are offered because they are spellings that work: a completion that +// hid them would make a working command look like a typo. +func childNames(cmd *Command) []string { + names := make([]string, 0, len(cmd.Children)) + + for _, child := range cmd.Children { + names = append(names, child.Name) + names = append(names, child.Aliases...) + } + + sort.Strings(names) + + return names +} + +// flagNames is every long flag a command accepts, its own and the globals. +// +// --help is added because the dispatcher answers it everywhere without any +// command registering it, so reading the FlagSets alone would leave out the one +// flag that works on everything. +func flagNames(cmd *Command, env *Env) []string { + fs := flag.NewFlagSet("completion", flag.ContinueOnError) + fs.SetOutput(discard{}) + + // Globals first, so a command that somehow registered the same name does + // not end up listed twice. + if env != nil { + env.registerGlobals(fs) + } + + if cmd.Flags != nil { + cmd.Flags(fs) + } + + seen := map[string]bool{"help": true} + + fs.VisitAll(func(f *flag.Flag) { seen[f.Name] = true }) + + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, "--"+name) + } + + sort.Strings(names) + + return names +} + +// discard swallows a FlagSet's usage output. +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +// bashCompletion is a script that walks the words typed so far. +// +// One case statement keyed on the command path, which is the shape bash makes +// cheapest: no arrays of arrays, no subshell per keystroke. +func bashCompletion(nodes []completionNode) string { + var b strings.Builder + + b.WriteString(`# vallic completion for bash. Generated by 'vallic completion bash'. +# +# Completes command names, subcommands and flags. Nothing here reaches the +# network: no environment or project names, deliberately, so TAB costs nothing +# and cannot spend an API allowance. + +_vallic() { + local cur words path key + cur="${COMP_WORDS[COMP_CWORD]}" + + # The command path is the non-flag words before the cursor, minus the + # binary. A flag's value is skipped with it, or "--project acme list" would + # read as the path "acme list". + path=() + local i=1 + while (( i < COMP_CWORD )); do + case "${COMP_WORDS[i]}" in + -*) (( i++ )) ;; + *) path+=("${COMP_WORDS[i]}") ;; + esac + (( i++ )) + done + + key="${path[*]}" + + case "$key" in +`) + + for _, node := range nodes { + words := append(append([]string{}, node.Subcommands...), node.Flags...) + + fmt.Fprintf(&b, " %s)\n COMPREPLY=($(compgen -W %q -- \"$cur\"))\n return\n ;;\n", + bashPattern(node.Path), strings.Join(words, " ")) + } + + b.WriteString(` esac +} + +complete -F _vallic vallic +`) + + return b.String() +} + +// bashPattern is the case label for a path. +// +// Quoted, always. A label of more than one word is a syntax error unquoted -- +// `team list)` stops bash at `list` -- which a generator is uniquely good at +// producing and uniquely bad at noticing, because the script is only ever read +// by a shell. +func bashPattern(path []string) string { + return `"` + strings.Join(path, " ") + `"` +} + +// zshCompletion is a script keyed the same way, in zsh's own idiom. +func zshCompletion(nodes []completionNode) string { + var b strings.Builder + + b.WriteString(`#compdef vallic +# vallic completion for zsh. Generated by 'vallic completion zsh'. +# +# Completes command names, subcommands and flags. Nothing here reaches the +# network: no environment or project names, deliberately, so TAB costs nothing +# and cannot spend an API allowance. + +_vallic() { + local -a path + local key word + + # The non-flag words before the cursor, minus the binary. A flag's value is + # skipped with it. + local i=2 + while (( i < CURRENT )); do + word="${words[i]}" + if [[ "$word" == -* ]]; then + (( i++ )) + else + path+=("$word") + fi + (( i++ )) + done + + key="${(j: :)path}" + + case "$key" in +`) + + for _, node := range nodes { + words := append(append([]string{}, node.Subcommands...), node.Flags...) + + fmt.Fprintf(&b, " %s)\n compadd -- %s\n return\n ;;\n", + zshPattern(node.Path), strings.Join(words, " ")) + } + + b.WriteString(` esac +} + +_vallic "$@" +`) + + return b.String() +} + +// zshPattern is the case label for a path. Quoted for bashPattern's reason. +func zshPattern(path []string) string { + return `"` + strings.Join(path, " ") + `"` +} + +// fishCompletion is a list of rules rather than a script. +// +// fish has no case statement to key on, so each command is a `complete` line +// guarded by `__fish_seen_subcommand_from`, which is the idiom and reads far +// better than reimplementing the walk. +func fishCompletion(nodes []completionNode) string { + var b strings.Builder + + b.WriteString(`# vallic completion for fish. Generated by 'vallic completion fish'. +# +# Completes command names, subcommands and flags. Nothing here reaches the +# network: no environment or project names, deliberately, so TAB costs nothing +# and cannot spend an API allowance. + +`) + + for _, node := range nodes { + condition := "not __fish_seen_subcommand_from " + strings.Join(allFirstWords(nodes), " ") + + if len(node.Path) > 0 { + // Every word of the path having been seen. Not exact -- fish has no + // notion of order here -- and close enough that the wrong list is + // only ever offered for a command nobody typed. + condition = "__fish_seen_subcommand_from " + strings.Join(node.Path, "; and __fish_seen_subcommand_from ") + } + + for _, word := range node.Subcommands { + fmt.Fprintf(&b, "complete -c vallic -f -n %q -a %q\n", condition, word) + } + + for _, word := range node.Flags { + fmt.Fprintf(&b, "complete -c vallic -f -n %q -l %q\n", condition, strings.TrimPrefix(word, "--")) + } + } + + return b.String() +} + +// allFirstWords is every top-level command name, for fish's "nothing typed yet" +// guard. +func allFirstWords(nodes []completionNode) []string { + for _, node := range nodes { + if len(node.Path) == 0 { + return node.Subcommands + } + } + + return nil +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..4d22cb5 --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,153 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The generated bash script has to parse as bash. +// +// This is the test that earns its keep. A completion script is read by a shell +// and never by a person, so a generator that emits something unparseable fails +// silently: TAB simply stops working, and nothing anywhere says why. The first +// version of this emitted `team list)` as a case label, which is a syntax error +// at `list` — every multi-word command in the tree was broken, and `go test`, +// `go vet` and reading the output all looked fine. +func TestTheGeneratedBashScriptParses(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("no bash to check against") + } + + script := filepath.Join(t.TempDir(), "vallic.bash") + + if err := os.WriteFile(script, []byte(bashCompletion(describeTree(Root(), &Env{}))), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + out, err := exec.Command(bash, "-n", script).CombinedOutput() + if err != nil { + t.Fatalf("bash -n rejected the script: %v\n%s", err, out) + } +} + +// And it has to complete, not merely parse. +// +// Driven the way bash drives it — COMP_WORDS, COMP_CWORD, read COMPREPLY — +// because everything about this only exists inside a shell, and a test that +// asserted on the generated text would be asserting on the shape of the +// generator rather than on whether TAB works. +func TestTheBashScriptCompletesCommandsSubcommandsAndFlags(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("no bash to drive") + } + + dir := t.TempDir() + script := filepath.Join(dir, "vallic.bash") + + if err := os.WriteFile(script, []byte(bashCompletion(describeTree(Root(), &Env{}))), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + + for _, tc := range []struct { + name string + words []string + want []string + }{ + {"a bare prefix", []string{"vallic", "bu"}, []string{"build"}}, + {"a noun's subcommands", []string{"vallic", "env", ""}, []string{"list", "create", "info"}}, + {"a command's own flag", []string{"vallic", "build", "--"}, []string{"--deploy"}}, + {"a flag added elsewhere", []string{"vallic", "release", "list", "--br"}, []string{"--branch"}}, + + // The one that is easy to get wrong: a global flag's *value* is not a + // command, so the path here is "env" and not "acme env". + {"a flag value is not part of the path", []string{"vallic", "--project", "acme", "env", ""}, []string{"list"}}, + } { + t.Run(tc.name, func(t *testing.T) { + got := complete(t, bash, script, tc.words) + + for _, want := range tc.want { + if !strings.Contains(" "+got+" ", " "+want+" ") { + t.Errorf("completing %q offered %q, want it to include %q", tc.words, got, want) + } + } + }) + } +} + +// complete runs the script's function against one set of words. +func complete(t *testing.T, bash, script string, words []string) string { + t.Helper() + + var quoted []string + for _, word := range words { + quoted = append(quoted, "'"+word+"'") + } + + program := strings.Join([]string{ + "source " + script, + "COMP_WORDS=(" + strings.Join(quoted, " ") + ")", + "COMP_CWORD=" + itoa(len(words)-1), + "COMPREPLY=()", + "_vallic", + `printf '%s\n' "${COMPREPLY[*]}"`, + }, "\n") + + out, err := exec.Command(bash, "-c", program).CombinedOutput() + if err != nil { + t.Fatalf("driving completion: %v\n%s", err, out) + } + + return strings.TrimSpace(string(out)) +} + +// itoa without importing strconv for one call site. +func itoa(n int) string { + if n == 0 { + return "0" + } + + var digits []byte + + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + + return string(digits) +} + +// Every command the tree has is offered somewhere. +// +// The point of generating from the tree rather than writing a list: a command +// added without a thought for completion is completed anyway. Asserted so that +// a future refactor which loses a branch of the walk fails here rather than in +// somebody's terminal. +func TestEveryCommandInTheTreeIsCompletable(t *testing.T) { + root := Root() + script := bashCompletion(describeTree(root, &Env{})) + + var missing []string + + var walk func(cmd *Command, path string) + + walk = func(cmd *Command, path string) { + for _, child := range cmd.Children { + if !strings.Contains(script, `"`+strings.TrimSpace(path+" "+child.Name)+`"`) { + missing = append(missing, strings.TrimSpace(path+" "+child.Name)) + } + + walk(child, strings.TrimSpace(path+" "+child.Name)) + } + } + + walk(root, "") + + if len(missing) > 0 { + t.Errorf("not completable: %s", strings.Join(missing, ", ")) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d45d54e..dffca66 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -56,6 +56,9 @@ to production.`, // Deploying. deployCommand(), rollbackCommand(), + // Before deploy in the list because it is before it in the + // order of work: a build produces the release a deploy ships. + buildCommand(), redeployCommand(), releaseCommand(), activityCommand(), @@ -63,6 +66,10 @@ to production.`, // activity because the two names are the ones people confuse. logsCommand(), + // Shell plumbing. Last because it is about the terminal rather + // than about anything on the platform. + completionCommand(), + // Keeping this binary current. A question about the binary // itself rather than about anything on the platform, which is // why it sits on its own: everything above asks the control diff --git a/internal/cli/variable.go b/internal/cli/variable.go index 77ba855..90ac70b 100644 --- a/internal/cli/variable.go +++ b/internal/cli/variable.go @@ -27,8 +27,9 @@ func varCommand() *Command { Aliases: []string{"variable"}, Summary: "read and write environment variables", Long: `A variable reaches a container through the rendered .env, which is -written on a deploy — so setting one does not change a running site until the -next one. +written on a deploy or by ` + "`var apply`" + ` — so setting one does not change a running +site until one of those. Set several, then apply once: every apply restarts +the site. A secret is never readable, by anybody, once written. That is the point of marking one: the platform stores it and does not hand it back. ` + "`var list`" + ` @@ -44,6 +45,7 @@ environment that does not override it.`, varGetCommand(), varSetCommand(), varDeleteCommand(), + varApplyCommand(), }, } } @@ -80,6 +82,7 @@ environment overrides it.`, var ( variables []api.Variable where string + pending *bool ) if chosen == scopeProject { @@ -101,7 +104,12 @@ environment overrides it.`, } where = target.Environment.Name - variables, err = client.EnvironmentVariables(ctx, target.Environment.ID) + + var list *api.VariableList + list, err = client.EnvironmentVariables(ctx, target.Environment.ID) + if list != nil { + variables, pending = list.Variables, list.Pending + } } if err != nil { @@ -121,10 +129,29 @@ environment overrides it.`, }) } - return env.Printer.Print(table, map[string]any{ + data := map[string]any{ chosen: where, "variables": variables, - }) + } + + // Only where the control plane said. Absent rather than false + // when it did not, so a script can tell the two apart. + if pending != nil { + data["pending"] = *pending + } + + if err := env.Printer.Print(table, data); err != nil { + return err + } + + // After the table, where it is read: the values listed are the + // saved ones, and this is the one case where the site disagrees. + if pending != nil && *pending && !env.Printer.Structured() { + env.Printer.Warn("not applied yet: %s is still running with the previous values", where) + env.Printer.Say(" `vallic var apply %s` restarts it with these, or they go out with the next deploy", where) + } + + return nil }, } } @@ -326,6 +353,7 @@ which keeps it out of shell history and out of ` + "`ps`" + `: // platform ignored them. In the control plane's own words, so it // stays true if the platform's answer ever changes. env.Printer.Say(" it reaches the site on the %s", takesEffect(result.TakesEffect)) + env.Printer.Say(" or now, with `vallic var apply%s`", applyHint(chosen)) if chosen == scopeProject { env.Printer.Say(" every environment that does not override it will see it") @@ -405,12 +433,119 @@ one with ` + "`--scope project`" + `, deliberately.`, env.Printer.Good("%s removed from %s", name, where) env.Printer.Say(" it leaves the site on the %s", takesEffect(removed.TakesEffect)) + env.Printer.Say(" or now, with `vallic var apply%s`", applyHint(chosen)) return nil }, } } +func varApplyCommand() *Command { + var scope string + + return &Command{ + Name: "apply", + Summary: "restart the site with its variables now", + Usage: "var apply [] [--scope project]", + Long: `Setting a variable does not change a running site: its containers keep +the values they were started with until the next deploy. apply sends them now +instead. The site and its workers restart with the saved values, which takes a +few seconds; the database and other services keep running. + +Set several, then apply once: every apply is a restart. + +` + "`--scope project`" + ` applies to every environment still running on values the +project has since changed, and names each. Where none is behind, it says so +and succeeds, so a script can run it after every change. + +It exits non-zero when an environment could not be reached just now — a +machine being rebuilt, say. Its variables then go out with its next deploy, +or run it again in a minute.`, + Flags: scopeFlag(&scope), + Run: func(ctx context.Context, env *Env, args []string) error { + chosen, err := variableScope(scope) + if err != nil { + return err + } + + client, err := env.Client() + if err != nil { + return err + } + + var result *api.VariablesApplied + + if chosen == scopeProject { + if err := ensureNoExtra(nil, args, 0); err != nil { + return err + } + + project, resolveErr := env.ResolveProject(ctx) + if resolveErr != nil { + return resolveErr + } + + result, err = client.ApplyProjectVariables(ctx, project.ID) + } else { + if err := ensureNoExtra(nil, args, 1); err != nil { + return err + } + + target, resolveErr := env.ResolveEnvironment(ctx, first(args)) + if resolveErr != nil { + return resolveErr + } + + result, err = client.ApplyEnvironmentVariables(ctx, target.Environment.ID) + } + + if err != nil { + return err + } + + if env.Printer.Structured() { + if err := env.Printer.Value(result); err != nil { + return err + } + } else { + reportApplied(env, result) + } + + if len(result.Unreachable) > 0 { + return fmt.Errorf("%d environment(s) could not be reached; their variables go out with the next deploy", len(result.Unreachable)) + } + + return nil + }, + } +} + +// reportApplied says what `var apply` did, one line per environment. +func reportApplied(env *Env, result *api.VariablesApplied) { + if len(result.Applied) == 0 && len(result.Unreachable) == 0 { + env.Printer.Say("Every environment already has these values. Nothing to apply.") + + return + } + + for _, slug := range result.Applied { + env.Printer.Good("%s is restarting with its variables", slug) + } + + for _, slug := range result.Unreachable { + env.Printer.Warn("%s could not be reached just now", slug) + } +} + +// applyHint is the flag `var apply` needs to act where a write just did. +func applyHint(scope string) string { + if scope == scopeProject { + return " --scope project" + } + + return "" +} + // scopeFlag registers --scope on a command. // // Not --project, which is already global and names *which* project. Two diff --git a/internal/cli/variable_test.go b/internal/cli/variable_test.go index fa11211..ef5e94b 100644 --- a/internal/cli/variable_test.go +++ b/internal/cli/variable_test.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "testing" + + "github.com/vallic/vallic-cli/internal/api" ) func TestVariableScopeRefusesAnythingElse(t *testing.T) { @@ -115,3 +117,48 @@ func TestReadValueRefusesMoreThanAVariableHolds(t *testing.T) { t.Errorf("readValue() read %d bytes, want %d", len(got), maxVariable) } } + +// Nothing behind is an answer, not a failure: a script that applies after +// every change must not fail on the run where a deploy got there first. +func TestApplyWithNothingBehindSaysSo(t *testing.T) { + env, out, errOut := followEnv(t) + + reportApplied(env, &api.VariablesApplied{}) + + if !strings.Contains(out.String()+errOut.String(), "Nothing to apply") { + t.Errorf("output = %q, want it to say there was nothing to apply", out.String()+errOut.String()) + } +} + +// Each environment is named, and one that could not be reached is told apart +// from one that is restarting. +func TestApplyNamesEachEnvironment(t *testing.T) { + env, out, errOut := followEnv(t) + + reportApplied(env, &api.VariablesApplied{ + Applied: []string{"acme-staging"}, + Unreachable: []string{"acme-production"}, + }) + + all := out.String() + errOut.String() + + if !strings.Contains(all, "acme-staging is restarting") { + t.Errorf("output = %q, want the applied environment named", all) + } + + if !strings.Contains(all, "acme-production could not be reached") { + t.Errorf("output = %q, want the unreachable environment named", all) + } +} + +// The hint after a write has to name the scope the write was made in, or it +// sends somebody who set a project variable to apply one environment. +func TestApplyHintFollowsTheScope(t *testing.T) { + if got := applyHint(scopeProject); got != " --scope project" { + t.Errorf("applyHint(project) = %q", got) + } + + if got := applyHint(scopeEnvironment); got != "" { + t.Errorf("applyHint(environment) = %q, want nothing", got) + } +}