`felis setup --dev` promised "install the dev channel (main HEAD)" and installed release: the flag only exported FELIS_CHANNEL, a variable nothing in the tree reads. deploy/bootstrap.sh reads FELIS_VERSION_BOOTSTRAP. Renaming the variable would have been a worse bug than the dead one, because it would look wired. setup runs bootstrap with FELIS_BOOTSTRAP_FROM_TUI=1, and on that arm every reader of FELIS_VERSION_BOOTSTRAP is unreachable: the channel case and its validation live in resolve_install_ref, which the TUI path skips outright, and use_release_binary is only consulted by the elif that `if bootstrap_from_tui` already short-circuited. setup re-images the host from the felis binary it is itself running; there is no channel to pick. So the flag refuses, exits 2 and names FELIS_VERSION_BOOTSTRAP=dev on the installer, which is the mechanism that does work. Refusing beats defaulting: the operator asked for dev, and release is the one answer they did not want. The refusal precedes the root check, or an unprivileged operator gets told about sudo instead of about the channel. channelName had no other caller and goes with it. Nothing else referenced --dev -- no doc, no script, no test -- so this removes a promise the tree only ever made to itself.
122 lines
4.0 KiB
Go
122 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSetupConfigPathPrefersGeneratedHostConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
requested := filepath.Join(dir, "felis.toml")
|
|
host := filepath.Join(dir, "felis.host.toml")
|
|
|
|
if got := setupConfigPathFor(requested, host, false); got != requested {
|
|
t.Fatalf("without host config: got %q, want requested %q", got, requested)
|
|
}
|
|
if err := os.WriteFile(host, []byte("host"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := setupConfigPathFor(requested, host, false); got != host {
|
|
t.Fatalf("with host config: got %q, want host %q", got, host)
|
|
}
|
|
if got := setupConfigPathFor(requested, host, true); got != requested {
|
|
t.Fatalf("explicit config: got %q, want requested %q", got, requested)
|
|
}
|
|
}
|
|
|
|
func TestEnsureDefaultConfigLinkBacksUpStaleDefault(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("creating symbolic links requires an optional Windows privilege")
|
|
}
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "felis.toml")
|
|
host := filepath.Join(dir, "felis.host.toml")
|
|
if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(host, []byte("host"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ensureDefaultConfigLink(target, host); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link, err := os.Readlink(target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if link != host {
|
|
t.Fatalf("default config link = %q, want %q", link, host)
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foundBackup := false
|
|
for _, e := range entries {
|
|
foundBackup = foundBackup || strings.HasPrefix(e.Name(), "felis.toml.bak.")
|
|
}
|
|
if !foundBackup {
|
|
t.Fatal("stale default config was not backed up")
|
|
}
|
|
}
|
|
|
|
func TestHostBootstrapReadyRequiresMarkerAndArtifacts(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows files do not expose Unix executable mode bits")
|
|
}
|
|
dir := t.TempDir()
|
|
marker := filepath.Join(dir, "bootstrap.done")
|
|
hostConfig := filepath.Join(dir, "felis.host.toml")
|
|
hostBin := filepath.Join(dir, "felis")
|
|
kubeconfig := filepath.Join(dir, "k3s.yaml")
|
|
|
|
if err := os.WriteFile(marker, []byte("done"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) {
|
|
t.Fatal("bootstrap should not be ready with marker only")
|
|
}
|
|
|
|
for _, path := range []string{hostConfig, kubeconfig} {
|
|
if err := os.WriteFile(path, []byte("ok"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(hostBin, []byte("bin"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) {
|
|
t.Fatal("bootstrap should be ready when marker and host artifacts exist")
|
|
}
|
|
}
|
|
|
|
// --dev used to export FELIS_CHANNEL, which nothing reads, so `felis setup --dev`
|
|
// silently installed the RELEASE channel: the one outcome the operator did not ask
|
|
// for. Renaming the variable to the one bootstrap does read (FELIS_VERSION_BOOTSTRAP)
|
|
// would not have helped -- setup takes bootstrap's bootstrap_from_tui arm, where every
|
|
// reader of it is unreachable -- so the flag refuses instead of guessing. It has to
|
|
// refuse BEFORE the root check, or the message an unprivileged operator sees is about
|
|
// sudo rather than about the channel.
|
|
func TestSetupDevFlagRefusesInsteadOfSilentlyInstallingRelease(t *testing.T) {
|
|
var stdout, stderr strings.Builder
|
|
|
|
if code := cmdSetup([]string{"--dev"}, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("want exit 2 for an unsupported channel flag, got %d (stderr: %s)", code, stderr.String())
|
|
}
|
|
msg := stderr.String()
|
|
if !strings.Contains(msg, "FELIS_VERSION_BOOTSTRAP=dev") {
|
|
t.Errorf("the refusal must name the mechanism that actually works:\n%s", msg)
|
|
}
|
|
if strings.Contains(msg, "must run as root") {
|
|
t.Errorf("the channel refusal must precede the root check:\n%s", msg)
|
|
}
|
|
// The dead variable is gone; setting it again would re-create a knob nothing reads.
|
|
if _, ok := os.LookupEnv("FELIS_CHANNEL"); ok {
|
|
t.Errorf("FELIS_CHANNEL has no reader anywhere and must not be exported")
|
|
}
|
|
}
|