From 4e734f2b95df5dfb724a73a5dbc41ff9650d0e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Thu, 5 Nov 2026 01:44:46 +0100 Subject: [PATCH 1/2] Add suport for multiple domains & multi app runtime --- USAGE.md | 3 ++ internal/api/domains_promote_test.go | 31 +++++++++++++++++ internal/api/models.go | 17 +++++++++ internal/api/routes.go | 17 +++++++++ internal/cli/domain.go | 52 ++++++++++++++++++++++++++++ internal/cli/validate.go | 10 ++++++ 6 files changed, 130 insertions(+) diff --git a/USAGE.md b/USAGE.md index 1d6394b..0f51cb0 100644 --- a/USAGE.md +++ b/USAGE.md @@ -308,6 +308,9 @@ vallic domain list production vallic domain primary shop.example.com production vallic domain canonical shop.example.com production +# Where the repository declares a frontend: which application a hostname reaches +vallic domain application shop.example.com frontend 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 index 85fbdfb..464a811 100644 --- a/internal/api/domains_promote_test.go +++ b/internal/api/domains_promote_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" ) @@ -55,3 +56,33 @@ func TestPromoteDomainSaysWhetherTheOthersRedirect(t *testing.T) { }) } } + +// The application is sent on its own, so nothing else about the hostname moves. +func TestSetDomainApplicationSendsOnlyThat(t *testing.T) { + var sent map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&sent); err != nil { + t.Errorf("decoding: %v", err) + } + if r.Method != http.MethodPatch || !strings.HasSuffix(r.URL.Path, "/domains/5") { + t.Errorf("sent to %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"domain":{"id":5,"application":"frontend"},"message":"ok"}`)) + })) + defer server.Close() + + client := New(server.URL, anonymous{}, "test") + + updated, err := client.SetDomainApplication(context.Background(), 5, "frontend") + if err != nil { + t.Fatalf("SetDomainApplication: %v", err) + } + if updated.Domain.Application != "frontend" { + t.Errorf("application = %q", updated.Domain.Application) + } + if len(sent) != 1 || sent["application"] != "frontend" { + t.Errorf("sent %v", sent) + } +} diff --git a/internal/api/models.go b/internal/api/models.go index 0acff64..0085a44 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -692,6 +692,11 @@ type Domain struct { // environment and cannot be removed on its own. Kind string `json:"kind"` + // Application is which of the environment's applications it asks to reach: + // "backend" or "frontend". With no frontend deployed every hostname reaches + // the backend, whatever this says. + Application string `json:"application"` + // 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. @@ -1075,6 +1080,18 @@ type Manifest struct { // BackupExclude is what the files backup leaves out beside the // framework's own caches, as the file wrote it. BackupExclude []string `json:"backup_exclude"` + + // Frontend is a Node.js or Bun application beside this one, in a directory + // of its own. Nil where the repository declares none. + Frontend *ManifestFrontend `json:"frontend"` +} + +// ManifestFrontend is the application a repository runs beside its backend. +type ManifestFrontend struct { + Root string `json:"root"` + Runtime map[string]string `json:"runtime"` + Start string `json:"start"` + Port int `json:"port"` } // ManifestService is one service a repository asked to run beside it. diff --git a/internal/api/routes.go b/internal/api/routes.go index f6cdb1e..882cc86 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -490,6 +490,23 @@ func (c *Client) PromoteDomain(ctx context.Context, domainID int, canonical bool return &out, nil } +// SetDomainApplication says which of the environment's applications a hostname +// reaches: "backend" or "frontend". +func (c *Client) SetDomainApplication(ctx context.Context, domainID int, application string) (*DomainUpdated, error) { + body, err := json.Marshal(map[string]any{"application": application}) + 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 f781485..a6e8edf 100644 --- a/internal/cli/domain.go +++ b/internal/cli/domain.go @@ -28,6 +28,7 @@ actually being routed.`, domainVerifyCommand(), domainPrimaryCommand(), domainCanonicalCommand(), + domainApplicationCommand(), domainDeleteCommand(), }, } @@ -271,6 +272,57 @@ hostname cannot, because your own domains would redirect to it.`, } } +func domainApplicationCommand() *Command { + return &Command{ + Name: "application", + Summary: "send a hostname to the backend or the frontend", + Usage: "domain application []", + Long: `Says which of the environment's applications one of your own hostnames +reaches, where the repository declares a frontend beside its backend. Your +hostnames reach the backend until you send them to the frontend. + +It stops being primary or canonical as it moves: those are of the application +it leaves. Choose again among the hostnames of the one it joins.`, + Run: func(ctx context.Context, env *Env, args []string) error { + if len(args) < 2 { + return Usagef(nil, "name a hostname, and backend or frontend") + } + + if args[1] != "backend" && args[1] != "frontend" { + return Usagef(nil, "%q is neither backend nor frontend", args[1]) + } + + target, err := env.ResolveEnvironment(ctx, firstOf(args[2:])) + if err != nil { + return err + } + + client, err := env.Client() + if err != nil { + return err + } + + domain, err := findDomain(ctx, client, target.Environment.ID, args[0]) + if err != nil { + return err + } + + updated, err := client.SetDomainApplication(ctx, domain.ID, args[1]) + if err != nil { + return err + } + + if env.Printer.Structured() { + return env.Printer.Value(updated) + } + + env.Printer.Good("%s", updated.Message) + + return nil + }, + } +} + // 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 { diff --git a/internal/cli/validate.go b/internal/cli/validate.go index d3af64a..6b1e7f1 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -331,6 +331,7 @@ func manifestEntries(manifest *api.Manifest) []manifestEntry { add("mounts", strings.Join(manifest.Mounts, ", ")) add("extras", manifestExtras(manifest.Extras)) add("backup exclude", strings.Join(manifest.BackupExclude, ", ")) + add("frontend", manifestFrontend(manifest.Frontend)) return entries } @@ -412,6 +413,15 @@ func manifestWorkers(workers []api.Worker) string { return strings.Join(parts, ", ") } +// manifestFrontend renders the application beside the backend, or nothing. +func manifestFrontend(frontend *api.ManifestFrontend) string { + if frontend == nil { + return "" + } + + return frontend.Root + " (" + settingsLine(frontend.Runtime) + "), " + frontend.Start +} + // manifestExtras renders the customer's own machines and how to reach them. // // The ports are the point. An extra is a machine running software this platform From cabb5c9f0a040d0a369a9cd0464c75d2f2361b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentino=20Me=C4=91imorec?= Date: Fri, 13 Nov 2026 16:30:30 +0100 Subject: [PATCH 2/2] Add log follow / download --- README.md | 2 +- USAGE.md | 8 +- internal/api/models.go | 6 + internal/cli/logs.go | 597 +++++++++++++++++++++++++++++++++++--- internal/cli/logs_test.go | 183 +++++++++++- internal/cli/root.go | 4 +- 6 files changed, 760 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ff55134..e2cdd3a 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ when it is left off. | `url [--all] [--open]` | The browsable address, alone on stdout. `--all` lists every name the edge routes | | `deploy` · `rollback` · `redeploy` · `release list` | Shipping | | `activity list` · `activity get` · `activity log` · `activity cancel` | What the platform has been doing, the log of one task, and calling off one that has not started | -| `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 | +| `logs [--follow] [--since] [--source] [--output]` | The site's own logs, read off its machines over your SSH key: containers, cron, the application's log files and the proxy's requests. `--follow` is a live tail, `--output` saves a window to a file, `--where` says where the project sends them besides | | `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 | diff --git a/USAGE.md b/USAGE.md index 0f51cb0..3eb6992 100644 --- a/USAGE.md +++ b/USAGE.md @@ -137,8 +137,14 @@ vallic activity log 1234 --follow # Call off a deploy still waiting in the queue; one already running finishes vallic activity cancel 1235 -# Where the site's logs go +# The site's own logs: the last 100 lines, a live tail, a window, one kind vallic logs production +vallic logs production --follow +vallic logs production --since 1h --source proxy +vallic logs production --since 24h --output production.log.gz + +# Where the project sends them besides +vallic logs production --where # CPU, memory and disk: now, on average and at peak vallic metrics production diff --git a/internal/api/models.go b/internal/api/models.go index 0085a44..88b41cb 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -196,6 +196,12 @@ type SSHTarget struct { DBSize string `json:"db_size"` DBExportOptions []string `json:"db_export_options"` + // Logs is the verb that reads the environment's own logs off the + // machine, for `vallic logs`. Empty where a machine's agent is older + // than the shell that answers it, and against a control plane that + // predates it. + Logs string `json:"logs"` + // FilesPath is the one writable directory that is the same for every // project type and survives every deploy. What rsync should be aimed at. FilesPath string `json:"files_path"` diff --git a/internal/cli/logs.go b/internal/cli/logs.go index 9d139d5..08b64e7 100644 --- a/internal/cli/logs.go +++ b/internal/cli/logs.go @@ -1,72 +1,605 @@ package cli import ( + "bufio" + "compress/gzip" "context" "errors" + "flag" "fmt" + "io" "net/http" + "os" + "os/exec" + "slices" + "strconv" + "strings" + "sync" "github.com/vallic/vallic-cli/internal/api" + vssh "github.com/vallic/vallic-cli/internal/ssh" ) -// logsCommand says where an environment's logs are. +// logSources are the kinds of line an environment's logs hold, as --source +// takes them. +var logSources = []string{"app", "container", "cron", "proxy", "proxy-error"} + +// logsCommand reads an environment's logs off its machines, or follows them. // -// Not a tail. A site's request and error logs go from its machine's journal -// to the team's own destination and never to the control plane, so there is -// nothing here to stream — and a command that pretended to would show -// something other than what the team's destination holds. What the platform -// can say honestly is where they are. See the control plane's docs/cli.md, -// "Open decisions", for what reading them from here would take. +// The lines never pass through the control plane. Each machine keeps a week +// of what the environment's containers, cron jobs, application and proxy +// wrote; this logs in to the machine with the person's own SSH key, the way +// `vallic ssh` does, and the agent there hands over what is this +// environment's and nothing else. So it works for whoever has a shell, costs +// the control plane nothing, and a tail is as live as the machine is. func logsCommand() *Command { + var ( + identity string + machine string + since string + until string + output string + tail int + follow bool + asJSON bool + where bool + dryRun bool + sources kindList + ) + return &Command{ Name: "logs", - Summary: "say where an environment's logs are", - Usage: "logs []", - Long: `Where this environment's request and error logs go: the destination the -project chose (Grafana Loki, Datadog, …), and the copy each machine keeps for -itself. They are read there — Vallic Cloud does not keep them. - -What the platform did to the environment — deploys, provisioning, backups — -is a different log, and ` + "`vallic activity`" + ` reads it.`, + Summary: "read or follow an environment's logs", + Usage: "logs [] [--follow] [--since ] [--tail ] [--source ]... [--machine ] [--output ]", + Long: `What the environment wrote on its machines: its containers' output, its cron +jobs, the files its application logs to, and the requests the proxy served for +its hostnames. Each machine keeps the last 7 days. + +With nothing else it prints the last 100 lines. --follow keeps going, line by +line as they are written, until you stop it: + + vallic logs production --follow + vallic logs production --since 1h --source proxy + vallic logs staging --since 2026-11-13T10:00 --until 2026-11-13T10:30 + vallic logs production --since 24h --output production.log.gz + +--since and --until take how long ago (15m, 2h, 3d) or a moment, in UTC. +--source takes app, container, cron, proxy or proxy-error, and can be given +more than once. An environment on several machines is read on all of them, +each line named for its machine; --machine reads one. + +The lines come straight from the machine over your SSH key, as vallic ssh +does, and are not kept by Vallic Cloud. --where says where the project sends +them besides. What the platform did to the environment — deploys, +provisioning, backups — is a different log: vallic activity.`, + Flags: func(fs *flag.FlagSet) { + identityFlag(fs, &identity) + fs.BoolVar(&follow, "follow", false, "keep printing lines as they are written") + fs.BoolVar(&follow, "f", false, "keep printing lines as they are written") + fs.StringVar(&since, "since", "", "from how long ago (15m, 2h, 3d) or a moment (2026-11-13T10:00)") + fs.StringVar(&until, "until", "", "up to how long ago or a moment") + fs.IntVar(&tail, "tail", -1, "only the last so many lines (default 100, or 10 with --follow)") + fs.IntVar(&tail, "n", -1, "only the last so many lines") + fs.Var(&sources, "source", "a kind of log: "+strings.Join(logSources, ", ")+" (repeatable)") + fs.StringVar(&machine, "machine", "", "read one of the environment's machines instead of all of them") + fs.StringVar(&output, "output", "", "write the lines to a file instead of printing them (.gz compresses)") + fs.StringVar(&output, "o", "", "write the lines to a file instead of printing them") + fs.BoolVar(&asJSON, "json", false, "one JSON object a line: time, source, name, message") + fs.BoolVar(&where, "where", false, "say where the project sends its logs, instead of reading them") + fs.BoolVar(&dryRun, "dry-run", false, "print the ssh command instead of running it") + }, Run: func(ctx context.Context, env *Env, args []string) error { - target, err := env.ResolveEnvironment(ctx, first(args)) + if err := ensureNoExtra(nil, args, 1); err != nil { + return err + } + + if where { + return logsWhere(ctx, env, first(args)) + } + + request := logsRequest{ + Since: since, + Until: until, + Tail: tail, + Follow: follow, + JSON: asJSON || env.Printer.Structured(), + Sources: sources, + } + + if err := request.check(output); err != nil { + return err + } + + target, err := env.sshTarget(ctx, first(args), identity) if err != nil { return err } - client, err := env.Client() + // The verb the control plane named, as the database's are: empty + // from a control plane that predates it, and where a machine's + // agent would pass the word to a container as a command. + if target.Logs == "" { + return fmt.Errorf("this environment's machines cannot be read for logs yet: they update on their own, usually within the hour\n where the project sends its logs meanwhile: vallic logs --where") + } + + streams, err := logStreams(target, machine, request.verb(target.Logs)) if err != nil { return err } - answer, err := client.Logs(ctx, target.Environment.ID) + if dryRun { + for _, stream := range streams { + env.Printer.Line("%s", vssh.Describe(stream.cmd)) + } - var refusal *api.Error - if errors.As(err, &refusal) && refusal.Status == http.StatusNotFound { - // An older control plane. Said plainly: the console's - // configuration page has the same answer. - return fmt.Errorf("this control plane does not say where logs go yet\n the project's configuration page in the console has it") + return nil } + + if _, err := exec.LookPath("ssh"); err != nil { + return fmt.Errorf("ssh is not installed, and this command needs it") + } + + out, done, err := logsOutput(env, output) if err != nil { return err } - if env.Printer.Structured() { - return env.Printer.Value(answer) + lines, err := runLogStreams(ctx, streams, out, env.Err, request) + + if closeErr := done(); err == nil { + err = closeErr } - for _, line := range logsReport(answer) { - env.Printer.Line("%s", line) + if err == nil && output != "" { + env.Printer.Say("Wrote %d lines to %s.", lines, output) } - return nil + return err }, } } -// logsReport is what `vallic logs` prints, one line each. +// kindList is a flag given more than once, or as a comma-separated list. +type kindList []string + +func (l *kindList) String() string { return strings.Join(*l, ",") } + +func (l *kindList) Set(value string) error { + for _, kind := range strings.Split(value, ",") { + if kind = strings.TrimSpace(kind); kind != "" && !slices.Contains(*l, kind) { + *l = append(*l, kind) + } + } + + return nil +} + +// logsRequest is what was asked for, before it is a command. +type logsRequest struct { + Since string + Until string + Tail int + Follow bool + JSON bool + Sources []string +} + +// check says what is wrong with a request before a connection is opened for +// it. The machine checks again; this is the answer that names the flag. +func (r logsRequest) check(output string) error { + for _, source := range r.Sources { + if !slices.Contains(logSources, source) { + return Usagef(nil, "--source takes %s; not %q", strings.Join(logSources, ", "), source) + } + } + + for flag, value := range map[string]string{"--since": r.Since, "--until": r.Until} { + // One word, because it travels as one: a moment with a space in it + // is written with a T. + if strings.ContainsAny(value, " \t'\"\\;|&$`<>()") { + return Usagef(nil, "%s takes how long ago (15m, 2h, 3d) or a moment written as one word (2026-11-13T10:00), not %q", flag, value) + } + } + + if r.Follow && r.Until != "" { + return Usagef(nil, "--follow has no end, so it does not go with --until") + } + + if r.Follow && output != "" { + return Usagef(nil, "--follow has no end, so there is no file to finish; redirect it instead: vallic logs --follow > file") + } + + return nil +} + +// verb is the request as the forced command on the machine reads it. +// +// Words only, each of them checked above or a number: the machine's side +// takes them apart on spaces and hands nothing to a shell. +func (r logsRequest) verb(name string) string { + words := []string{name} + + if r.Since != "" { + words = append(words, "--since", r.Since) + } + if r.Until != "" { + words = append(words, "--until", r.Until) + } + + tail := r.Tail + if tail < 0 && r.Since == "" && r.Until == "" { + // Nothing said about how much: the end of it, as `tail` gives. + tail = 100 + if r.Follow { + tail = 10 + } + } + if tail > 0 { + words = append(words, "--tail", strconv.Itoa(tail)) + } + + if len(r.Sources) > 0 { + words = append(words, "--source", strings.Join(r.Sources, ",")) + } + if r.Follow { + words = append(words, "--follow") + } + if r.JSON { + words = append(words, "--json") + } + + return strings.Join(words, " ") +} + +// logStream is one machine being read. +type logStream struct { + // name is the machine's, or empty where the environment has one. + name string + cmd *exec.Cmd +} + +// logStreams builds a connection to each machine the logs are on. +// +// An environment's logs are its machines': the requests on the one traffic +// reaches, a worker's output on the worker's. So all of them are read unless +// one was named. +func logStreams(target *vssh.Target, machine, verb string) ([]logStream, error) { + if machine != "" { + if err := target.ChooseMachine(machine); err != nil { + return nil, err + } + + return []logStream{{name: "", cmd: target.Verb(verb, false)}}, nil + } + + if len(target.Machines) < 2 { + return []logStream{{name: "", cmd: target.Verb(verb, false)}}, nil + } + + streams := make([]logStream, 0, len(target.Machines)) + for _, m := range target.Machines { + one := *target + one.Machine = m.Name + streams = append(streams, logStream{name: m.Name, cmd: one.Verb(verb, false)}) + } + + return streams, nil +} + +// logsOutput is where the lines go: the terminal, or a file. +func logsOutput(env *Env, path string) (io.Writer, func() error, error) { + if path == "" { + return env.Out, func() error { return nil }, nil + } + + file, err := os.Create(path) + if err != nil { + return nil, nil, fmt.Errorf("opening %s: %w", path, err) + } + + if !strings.HasSuffix(path, ".gz") { + return file, file.Close, nil + } + + packed := gzip.NewWriter(file) + + return packed, func() error { + if err := packed.Close(); err != nil { + _ = file.Close() + + return err + } + + return file.Close() + }, nil +} + +// runLogStreams reads every machine and writes its lines out, and says how +// many there were. +// +// One machine is copied through as it arrives. Several are put in order of +// time when the read has an end, since each machine's lines already are; a +// tail that is followed has no end to wait for, so its lines are written as +// each machine sends them. +func runLogStreams(ctx context.Context, streams []logStream, out, errOut io.Writer, request logsRequest) (int, error) { + type running struct { + logStream + lines chan string + stderr *limitedBuffer + failed error + } + + all := make([]*running, 0, len(streams)) + + for _, stream := range streams { + one := &running{logStream: stream, lines: make(chan string, 256), stderr: &limitedBuffer{limit: 4 << 10}} + + stream.cmd.Stdin = nil + stream.cmd.Stderr = one.stderr + pipe, err := stream.cmd.StdoutPipe() + if err != nil { + return 0, err + } + if err := stream.cmd.Start(); err != nil { + return 0, fmt.Errorf("starting ssh: %w", err) + } + + go func() { + defer close(one.lines) + + reader := bufio.NewReaderSize(pipe, 64<<10) + for { + line, err := reader.ReadString('\n') + if line != "" { + one.lines <- strings.TrimRight(line, "\r\n") + } + if err != nil { + break + } + } + + one.failed = stream.cmd.Wait() + }() + + all = append(all, one) + } + + var ( + mu sync.Mutex + written int + failure error + ) + write := func(machine, line string) { + mu.Lock() + defer mu.Unlock() + + if failure != nil { + return + } + if _, err := io.WriteString(out, named(machine, line, request.JSON)+"\n"); err != nil { + failure = err + + return + } + written++ + } + + if request.Follow || len(all) == 1 { + var wait sync.WaitGroup + for _, one := range all { + wait.Add(1) + go func() { + defer wait.Done() + for line := range one.lines { + write(one.name, line) + } + }() + } + wait.Wait() + } else { + // The earliest line any machine is holding, until none holds one. + heads := make([]*string, len(all)) + next := func(i int) { + if line, ok := <-all[i].lines; ok { + heads[i] = &line + } else { + heads[i] = nil + } + } + for i := range all { + next(i) + } + for { + first := -1 + for i, head := range heads { + if head != nil && (first < 0 || stampOf(*head) < stampOf(*heads[first])) { + first = i + } + } + if first < 0 { + break + } + write(all[first].name, *heads[first]) + next(first) + } + } + + // A file that could not be written, or a terminal that went away. + if failure != nil && !errors.Is(failure, io.ErrClosedPipe) { + return written, failure + } + + var problems []string + for _, one := range all { + if problem := logsFailure(ctx, one.name, one.failed, one.stderr.String(), request.Follow); problem != "" { + problems = append(problems, problem) + } + } + + if len(problems) == len(all) && len(problems) > 0 { + return written, errors.New(strings.Join(problems, "\n")) + } + + // Some machines answered: what they said is printed, and the ones that + // did not are named beside it rather than failing the lot. + for _, problem := range problems { + fmt.Fprintln(errOut, "warning: "+problem) + } + + return written, nil +} + +// logsFailure turns how a connection ended into a sentence, or nothing where +// it ended well. +func logsFailure(ctx context.Context, machine string, err error, stderr string, follow bool) string { + if err == nil { + return "" + } + + // A tail somebody stopped ends with the connection being cut, which is + // how it was meant to end. + if follow && ctx.Err() != nil { + return "" + } + + var exit *exec.ExitError + if follow && errors.As(err, &exit) && (exit.ExitCode() == 130 || exit.ExitCode() == -1) { + return "" + } + + where := "" + if machine != "" { + where = machine + ": " + } + + stderr = strings.TrimSpace(stderr) + + // A machine whose agent is older than this passes the word to the + // container, which has never heard of it. + if strings.Contains(stderr, "vallic-logs: not found") || strings.Contains(stderr, "vallic-logs: command not found") { + return where + "this machine cannot be read for logs yet; it updates on its own, usually within the hour" + } + + if stderr != "" { + return where + strings.TrimPrefix(stderr, "vallic: ") + } + + return where + err.Error() +} + +// named puts the machine in front of a line, where there are several. +func named(machine, line string, asJSON bool) string { + if machine == "" { + return line + } + + if asJSON { + if rest, ok := strings.CutPrefix(line, "{"); ok { + return `{"machine":` + strconv.Quote(machine) + "," + rest + } + + return line + } + + return machine + " | " + line +} + +// stampOf is the time a line starts with, as text that sorts as time does. +// +// Both shapes the machine writes begin with it: `2026-11-13T10:00:00.000Z +// [container php] …`, and `{"time":"2026-11-13T10:00:00Z",…}`. A line with +// none sorts first, which keeps it where its machine put it. +func stampOf(line string) string { + if rest, ok := strings.CutPrefix(line, `{"time":"`); ok { + stamp, _, _ := strings.Cut(rest, `"`) + + return stamp + } + + if strings.HasPrefix(line, "{") || strings.HasPrefix(line, "-") { + return "" + } + + stamp, _, _ := strings.Cut(line, " ") + + return stamp +} + +// limitedBuffer keeps the start of what a connection said on its error +// stream, which is where a refusal is, and drops the rest. +type limitedBuffer struct { + mu sync.Mutex + limit int + text strings.Builder +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if room := b.limit - b.text.Len(); room > 0 { + if len(p) > room { + b.text.Write(p[:room]) + } else { + b.text.Write(p) + } + } + + return len(p), nil +} + +func (b *limitedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.text.String() +} + +// logsWhere says where an environment's logs are sent. +// +// Besides the machine's own copy, which `vallic logs` reads: a project can +// send its logs to a collector of its own, and this names it. +func logsWhere(ctx context.Context, env *Env, positional string) error { + target, err := env.ResolveEnvironment(ctx, positional) + if err != nil { + return err + } + + client, err := env.Client() + if err != nil { + return err + } + + answer, err := client.Logs(ctx, target.Environment.ID) + + var refusal *api.Error + if errors.As(err, &refusal) && refusal.Status == http.StatusNotFound { + // An older control plane. Said plainly: the console's + // configuration page has the same answer. + return fmt.Errorf("this control plane does not say where logs go yet\n the project's configuration page in the console has it") + } + if err != nil { + return err + } + + if env.Printer.Structured() { + return env.Printer.Value(answer) + } + + for _, line := range logsReport(answer) { + env.Printer.Line("%s", line) + } + + return nil +} + +// logsReport is what `vallic logs --where` prints, one line each. func logsReport(answer *api.Logs) []string { - lines := []string{fmt.Sprintf("Logs for %s are not kept by Vallic Cloud; they are read where they are sent.", answer.Environment)} + lines := []string{fmt.Sprintf("Where %s's logs are:", answer.Environment)} + + if answer.MachineDays > 0 { + lines = append(lines, fmt.Sprintf(" On the machine: the last %d days — read them with `vallic logs`, follow them with --follow.", answer.MachineDays)) + } switch { case answer.Destination != nil: @@ -82,10 +615,6 @@ func logsReport(answer *api.Logs) []string { lines = append(lines, " Sent to: nowhere — this project has no log destination.") } - if answer.MachineDays > 0 { - lines = append(lines, fmt.Sprintf(" On the machine: the last %d days, which support can read for you.", answer.MachineDays)) - } - if answer.SettingsURL != nil { lines = append(lines, fmt.Sprintf(" Change it: %s", *answer.SettingsURL)) } diff --git a/internal/cli/logs_test.go b/internal/cli/logs_test.go index 5c5cba3..b43da6d 100644 --- a/internal/cli/logs_test.go +++ b/internal/cli/logs_test.go @@ -1,11 +1,15 @@ package cli import ( + "context" "encoding/json" + "os/exec" + "strconv" "strings" "testing" "github.com/vallic/vallic-cli/internal/api" + vssh "github.com/vallic/vallic-cli/internal/ssh" ) func TestLogsNamesTheDestinationAndTheMachinesCopy(t *testing.T) { @@ -18,9 +22,9 @@ func TestLogsNamesTheDestinationAndTheMachinesCopy(t *testing.T) { }), "\n") for _, want := range []string{ - "Logs for acme-staging are not kept by Vallic Cloud", + "Where acme-staging's logs are:", "Sent to: Team Loki (Grafana Loki)", - "the last 7 days", + "the last 7 days — read them with `vallic logs`", "Change it: " + url, "vallic activity", } { @@ -63,3 +67,178 @@ func TestTheLogsAnswerDecodesAsTheControlPlaneSendsIt(t *testing.T) { t.Errorf("decoded as %+v", answer) } } + +// What was asked for travels as words the machine takes apart on spaces. +func TestLogsRequestIsSpelledAsTheMachineReadsIt(t *testing.T) { + for want, request := range map[string]logsRequest{ + // Nothing said about how much: the end of it. + "vallic-logs --tail 100": {Tail: -1}, + "vallic-logs --tail 10 --follow": {Tail: -1, Follow: true}, + "vallic-logs --since 1h": {Tail: -1, Since: "1h"}, + "vallic-logs --since 1h --tail 5 --follow": {Tail: 5, Since: "1h", Follow: true}, + "vallic-logs --since 2d --until 1d --json": {Tail: -1, Since: "2d", Until: "1d", JSON: true}, + "vallic-logs --tail 100 --source proxy,app": {Tail: -1, Sources: []string{"proxy", "app"}}, + "vallic-logs --since 2026-11-13T10:00 --tail 50": {Tail: 50, Since: "2026-11-13T10:00"}, + } { + if got := request.verb("vallic-logs"); got != want { + t.Errorf("%+v is spelled %q, want %q", request, got, want) + } + } +} + +// What cannot be sent as one word, or does not go together, is said here, +// naming the flag, before a connection is opened for it. +func TestLogsRequestThatCannotBeSentIsRefused(t *testing.T) { + for name, tc := range map[string]struct { + request logsRequest + output string + want string + }{ + "a kind there is not": {logsRequest{Sources: []string{"database"}}, "", "--source takes"}, + "a moment in two words": {logsRequest{Since: "2026-11-13 10:00"}, "", "written as one word"}, + "a command in a moment": {logsRequest{Until: "1h;reboot"}, "", "written as one word"}, + "a tail with an end": {logsRequest{Follow: true, Until: "1h"}, "", "does not go with --until"}, + "a tail into a file": {logsRequest{Follow: true}, "out.log", "redirect it instead"}, + } { + err := tc.request.check(tc.output) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: answered %v, want a refusal saying %q", name, err, tc.want) + } + } + + if err := (logsRequest{Since: "2026-11-13T10:00:00Z", Until: "15m", Sources: []string{"proxy-error"}}).check("out.log.gz"); err != nil { + t.Errorf("a request that is fine was refused: %v", err) + } +} + +// An environment on several machines is read on all of them unless one is +// named, and one machine is read as it is. +func TestLogsAreReadOnEveryMachine(t *testing.T) { + several := &vssh.Target{SSHTarget: api.SSHTarget{ + Host: "acme.vallic.cloud", Port: 2417, User: "vc-acme", + Machines: []api.SSHMachine{{Name: "front", Role: "proxy"}, {Name: "web-1", Role: "web"}, {Name: "web-2", Role: "web"}}, + }} + + streams, err := logStreams(several, "", "vallic-logs --tail 100") + if err != nil { + t.Fatal(err) + } + if len(streams) != 3 || streams[1].name != "web-1" { + t.Fatalf("read %d machines: %+v", len(streams), streams) + } + for _, stream := range streams { + command := vssh.Describe(stream.cmd) + if !strings.Contains(command, stream.name+".acme.vallic.cloud") || !strings.Contains(command, "vallic-logs --tail 100") { + t.Errorf("%s is read with %s", stream.name, command) + } + } + + one, err := logStreams(several, "web-2", "vallic-logs") + if err != nil || len(one) != 1 || one[0].name != "" || !strings.Contains(vssh.Describe(one[0].cmd), "web-2.acme.vallic.cloud") { + t.Errorf("naming a machine read %+v, %v", one, err) + } + + if _, err := logStreams(several, "web-9", "vallic-logs"); err == nil { + t.Error("a machine the environment does not have was read") + } + + single := &vssh.Target{SSHTarget: api.SSHTarget{Host: "acme.vallic.cloud", Port: 2417, User: "vc-acme"}} + if streams, err := logStreams(single, "", "vallic-logs"); err != nil || len(streams) != 1 || streams[0].name != "" { + t.Errorf("one machine read as %+v, %v", streams, err) + } +} + +// Several machines' lines come out in order of time, each named for its +// machine; one machine's come out as they are. +func TestSeveralMachinesAreReadInOrderOfTime(t *testing.T) { + script := func(lines ...string) *exec.Cmd { + return exec.Command("printf", "%s\n", strings.Join(lines, "\n")) + } + + streams := []logStream{ + {name: "front", cmd: script( + "2026-11-13T10:00:01.000Z [proxy acme] one", + "2026-11-13T10:00:04.000Z [proxy acme] four", + )}, + {name: "web-1", cmd: script( + "2026-11-13T10:00:02.000Z [container php] two", + "2026-11-13T10:00:03.000Z [container php] three", + "2026-11-13T10:00:05.000Z [container php] five", + )}, + } + + var out, errOut strings.Builder + lines, err := runLogStreams(context.Background(), streams, &out, &errOut, logsRequest{}) + if err != nil || lines != 5 { + t.Fatalf("read %d lines, %v; %s", lines, err, errOut.String()) + } + + want := "front | 2026-11-13T10:00:01.000Z [proxy acme] one\n" + + "web-1 | 2026-11-13T10:00:02.000Z [container php] two\n" + + "web-1 | 2026-11-13T10:00:03.000Z [container php] three\n" + + "front | 2026-11-13T10:00:04.000Z [proxy acme] four\n" + + "web-1 | 2026-11-13T10:00:05.000Z [container php] five\n" + if out.String() != want { + t.Errorf("the lines came out as\n%s", out.String()) + } + + out.Reset() + lines, err = runLogStreams(context.Background(), []logStream{{cmd: script("a line", "another")}}, &out, &errOut, logsRequest{}) + if err != nil || lines != 2 || out.String() != "a line\nanother\n" { + t.Errorf("one machine read %q, %d lines, %v", out.String(), lines, err) + } +} + +// As objects, a line says which machine it is from inside the object, and +// is ordered by the time it holds. +func TestLinesAsObjectsNameTheirMachine(t *testing.T) { + line := `{"time":"2026-11-13T10:00:00Z","source":"container","name":"php","message":"hello"}` + + got := named("web-1", line, true) + var entry map[string]string + if err := json.Unmarshal([]byte(got), &entry); err != nil { + t.Fatalf("%q is not an object: %v", got, err) + } + if entry["machine"] != "web-1" || entry["message"] != "hello" { + t.Errorf("the object reads %v", entry) + } + + if stampOf(line) != "2026-11-13T10:00:00Z" || stampOf("2026-11-13T10:00:00.000Z [cron] ran") != "2026-11-13T10:00:00.000Z" || stampOf("- [cron] ran") != "" { + t.Error("the time a line starts with was not read") + } + if named("", line, true) != line || named("", "text", false) != "text" { + t.Error("one machine's line was changed") + } +} + +// A machine that refuses, or whose agent is older than the verb, is said in +// a sentence; and where another machine answered, beside its lines. +func TestMachineThatCannotBeReadIsSaid(t *testing.T) { + fails := func(stderr string, code int) *exec.Cmd { + return exec.Command("sh", "-c", "echo '"+stderr+"' >&2; exit "+strconv.Itoa(code)) + } + + var out, errOut strings.Builder + _, err := runLogStreams(context.Background(), []logStream{{cmd: fails("sh: 1: vallic-logs: not found", 127)}}, &out, &errOut, logsRequest{}) + if err == nil || !strings.Contains(err.Error(), "cannot be read for logs yet") { + t.Errorf("an older agent was answered %v", err) + } + + _, err = runLogStreams(context.Background(), []logStream{{cmd: fails("vallic: logs: this account does not run acme on this machine", 255)}}, &out, &errOut, logsRequest{}) + if err == nil || err.Error() != "logs: this account does not run acme on this machine" { + t.Errorf("a refusal was answered %v", err) + } + + // One of two answered: its lines are printed and the other is named. + out.Reset() + lines, err := runLogStreams(context.Background(), []logStream{ + {name: "front", cmd: exec.Command("printf", "%s\n", "2026-11-13T10:00:01.000Z [proxy acme] one")}, + {name: "web-1", cmd: fails("ssh: connect to host web-1 port 2417: Connection timed out", 255)}, + }, &out, &errOut, logsRequest{}) + if err != nil || lines != 1 || !strings.Contains(out.String(), "front | ") { + t.Errorf("one machine of two read %q, %d lines, %v", out.String(), lines, err) + } + if !strings.Contains(errOut.String(), "warning: web-1: ssh: connect to host web-1") { + t.Errorf("the machine that did not answer was not named: %q", errOut.String()) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b41f9e0..d17cefe 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -70,8 +70,8 @@ to production.`, redeployCommand(), releaseCommand(), activityCommand(), - // Where the site's own logs are, which is not here. Beside - // activity because the two names are the ones people confuse. + // The site's own logs, read off its machines. Beside activity + // because the two names are the ones people confuse. logsCommand(), // Shell plumbing. Last because it is about the terminal rather