Add `felis update`, which reports which platform components have newer versions available, and route `felis version`, which shipped implemented but unreachable. That bug is why the subcommand router is now a map rather than a switch. cmdVersion existed with nothing dispatching to it and no usage line, so `felis version` fell through to "unknown command" and no test noticed — a switch offers no way to enumerate what it routes, so the usage text and the router could not be compared. As data, they can: a test now walks the Commands: block and the table in both directions, failing an entry added to one without the other. bootstrap-assets stays deliberately undocumented and is listed as such, which makes its absence a decision rather than an oversight. The host gatherer answers the two seams NewSysGatherer leaves nil, for the one caller that can satisfy them without a cluster client. felis-api is answered from the running binary's own build stamp rather than the Deployment's image tag: deploy/bootstrap.sh builds the image from the same checkout it installs /usr/local/bin/felis from and stamps both with one git describe, so it is the same artifact, and it is the identity `felis version` reports. Reading the Deployment answers a slightly different question — what is rolled out — and stays the right seam for the in-cluster path. Velocity is read from the jar's own META-INF/MANIFEST.MF Implementation-Version, which is what the proxy reports about itself at runtime, because bootstrap installs the jar under a fixed name with no version in it. The filename extractor remains only as a fallback for a hand-placed velocity-3.5.1.jar. An unstamped local build reports "dev" and is refused with an actionable message rather than being treated as 0.0.0, which would make every release upstream look like an upgrade. The panel and the plugin jars have no version of their own on purpose: they are embedded in or built alongside the felis binary, so the felis version is theirs.
193 lines
6.9 KiB
Go
193 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRunNoArgsPrintsUsage(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run(nil, &out, &errBuf); code != 2 {
|
|
t.Errorf("exit code = %d, want 2", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "Usage:") {
|
|
t.Errorf("expected usage on stderr, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunHelp(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run([]string{"help"}, &out, &errBuf); code != 0 {
|
|
t.Errorf("exit code = %d, want 0", code)
|
|
}
|
|
if !strings.Contains(out.String(), "felis") {
|
|
t.Errorf("expected usage on stdout, got %q", out.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUnknownCommand(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run([]string{"frobnicate"}, &out, &errBuf); code != 2 {
|
|
t.Errorf("exit code = %d, want 2", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "unknown command") {
|
|
t.Errorf("expected unknown-command error, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
// undocumentedCommands are routable on purpose but kept out of the usage text: they
|
|
// are called by deploy/bootstrap.sh, not by a human at a prompt. Listing them here is
|
|
// what makes their absence from usage a deliberate decision rather than an oversight.
|
|
var undocumentedCommands = map[string]bool{"bootstrap-assets": true}
|
|
|
|
// The usage text and the dispatch table must describe the same set of commands.
|
|
//
|
|
// This exists because the failure it catches already happened: `version` shipped
|
|
// implemented but unreachable — cmdVersion existed with nothing routing to it and no
|
|
// usage line — so `felis version` fell through to "unknown command", and no test
|
|
// noticed. Comparing the two lists is only possible because the router is a map; a
|
|
// switch cannot be enumerated.
|
|
//
|
|
// It compares names WITHOUT invoking anything. Running each command to see whether it
|
|
// is routed would start servers, dial clusters, and (for `update`) hit the network —
|
|
// a slow, flaky test of the wrong thing.
|
|
func TestUsageAndDispatchTableAgree(t *testing.T) {
|
|
documented := map[string]bool{}
|
|
var inCommands bool
|
|
for line := range strings.SplitSeq(usage, "\n") {
|
|
// Only the block under "Commands:" lists commands. The "Usage:" block above it
|
|
// has the same two-space indent but its entry is the "felis <command> [flags]"
|
|
// synopsis, which is not a subcommand.
|
|
if strings.HasPrefix(line, "Commands:") {
|
|
inCommands = true
|
|
continue
|
|
}
|
|
if !inCommands {
|
|
continue
|
|
}
|
|
// Command lines are the " <name> <description>" entries; the two-space indent
|
|
// distinguishes them from wrapped continuation lines.
|
|
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
|
|
continue
|
|
}
|
|
name, _, ok := strings.Cut(strings.TrimSpace(line), " ")
|
|
if !ok || name == "" {
|
|
continue
|
|
}
|
|
documented[name] = true
|
|
if _, routed := commands[name]; !routed {
|
|
t.Errorf("usage advertises %q but the dispatch table has no entry for it", name)
|
|
}
|
|
}
|
|
for name := range commands {
|
|
if !documented[name] && !undocumentedCommands[name] {
|
|
t.Errorf("%q is routable but undocumented; add it to usage, or to undocumentedCommands if it is an internal entrypoint", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunApplyRequiresFileFlag(t *testing.T) {
|
|
// Without -f the command must fail with usage (2), not try to contact a
|
|
// cluster. It can't return 0 because no CRD was created, and it can't return 1
|
|
// because that would be ambiguous with a real server-side failure.
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{"apply"}, &out, &errBuf)
|
|
if code != 2 {
|
|
t.Errorf("exit code = %d, want 2", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "missing required flag -f") {
|
|
t.Errorf("expected -f usage, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunApplyRejectsInvalidJSON(t *testing.T) {
|
|
// Sending garbage via a temp file must exit 1 (input error), not panic or hang.
|
|
empty := t.TempDir() + "/empty.json"
|
|
if err := os.WriteFile(empty, nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out, errBuf bytes.Buffer
|
|
code := run([]string{"apply", "-f", empty}, &out, &errBuf)
|
|
// The empty file must fail JSON parsing or validation; either way it exits 1.
|
|
if code != 1 {
|
|
t.Errorf("exit code = %d, want 1", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "invalid") && !strings.Contains(errBuf.String(), "required") {
|
|
t.Errorf("expected input error, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSetupAndBreakGlassCommands(t *testing.T) {
|
|
t.Run("setup help", func(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run([]string{"setup", "-h"}, &out, &errBuf); code != 0 {
|
|
t.Errorf("exit code = %d, want 0", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "Usage of setup") {
|
|
t.Errorf("expected setup help, got stderr=%q stdout=%q", errBuf.String(), out.String())
|
|
}
|
|
if strings.Contains(errBuf.String(), "Usage of breakGlass") {
|
|
t.Errorf("setup must not route to breakGlass help, got %q", errBuf.String())
|
|
}
|
|
})
|
|
|
|
t.Run("breakGlass help", func(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run([]string{"breakGlass", "-h"}, &out, &errBuf); code != 0 {
|
|
t.Errorf("exit code = %d, want 0", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "Usage of breakGlass") {
|
|
t.Errorf("expected breakGlass help, got stderr=%q stdout=%q", errBuf.String(), out.String())
|
|
}
|
|
})
|
|
|
|
t.Run("lowercase breakglass is intentionally rejected", func(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
if code := run([]string{"breakglass", "-h"}, &out, &errBuf); code != 2 {
|
|
t.Errorf("exit code = %d, want 2", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "unknown command") {
|
|
t.Errorf("expected lowercase alias rejection, got stderr=%q stdout=%q", errBuf.String(), out.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunReaperValidatesConfigBeforeDialing(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
// Like api, reaper must fail fast (exit 1) at config load, before any
|
|
// database or cluster contact.
|
|
code := run([]string{"reaper", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
|
|
if code != 1 {
|
|
t.Errorf("exit code = %d, want 1", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "felis reaper:") {
|
|
t.Errorf("expected reaper error on stderr, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAPIValidatesConfigBeforeDialing(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
// A non-existent config must fail fast (exit 1) at config load, before any
|
|
// database or cluster contact.
|
|
code := run([]string{"api", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
|
|
if code != 1 {
|
|
t.Errorf("exit code = %d, want 1", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "felis api:") {
|
|
t.Errorf("expected api error on stderr, got %q", errBuf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunMigrateRequiresUpVerb(t *testing.T) {
|
|
var out, errBuf bytes.Buffer
|
|
// "migrate" with no verb should fail fast on usage, not touch a database.
|
|
if code := run([]string{"migrate"}, &out, &errBuf); code != 2 {
|
|
t.Errorf("exit code = %d, want 2", code)
|
|
}
|
|
if !strings.Contains(errBuf.String(), "felis migrate up") {
|
|
t.Errorf("expected migrate usage, got %q", errBuf.String())
|
|
}
|
|
}
|