mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 08:45:41 +00:00
293 lines
9.7 KiB
Go
293 lines
9.7 KiB
Go
//go:build cli_tests
|
|
|
|
/*
|
|
Copyright NetFoundry Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
package cli_tests
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/openziti/edge-api/rest_util"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// Test_Quickstart_Cluster brings up a 3-node cluster via `ziti run quickstart cluster` then:
|
|
// - asserts every node's controller comes up and accepts an admin login. Only
|
|
// node 1 initializes the admin, so a successful login on nodes 2 and 3 proves
|
|
// raft replicated the admin identity and the cluster formed.
|
|
// - delivers a graceful stop to the parent (SIGINT on POSIX, a CTRL_BREAK
|
|
// console event on Windows) and asserts the auto-created temp home is removed.
|
|
// If the stop signal cannot be delivered (e.g. no console attached), it
|
|
// force-kills and skips the shutdown assertions.
|
|
func Test_Quickstart_Cluster(t *testing.T) {
|
|
zitiPath := os.Getenv("ZITI_CLI_TEST_ZITI_BIN")
|
|
if zitiPath == "" {
|
|
t.Skip("ZITI_CLI_TEST_ZITI_BIN not set")
|
|
}
|
|
if _, statErr := os.Stat(zitiPath); statErr != nil {
|
|
t.Fatalf("ziti binary not found at %s: %v", zitiPath, statErr)
|
|
}
|
|
|
|
const size = 3
|
|
// One contiguous block split in half so the ctrl range (base..base+size-1)
|
|
// and router range (base+size..base+2*size-1) never overlap.
|
|
base := findConsecutivePorts(t, size*2)
|
|
ctrlBase := base
|
|
routerBase := base + size
|
|
cfgDir := filepath.Join(t.TempDir(), "cli-config")
|
|
logPath := filepath.Join(t.TempDir(), "cluster.log")
|
|
logFile, err := os.Create(logPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = logFile.Close() }()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// No --home on purpose: the cluster creates a temp dir and removes it on a
|
|
// clean shutdown. Its path is read from the process output.
|
|
args := []string{
|
|
"run", "quickstart", "cluster",
|
|
"--size", strconv.Itoa(size),
|
|
"--ctrl-address", "localhost",
|
|
"--router-address", "localhost",
|
|
fmt.Sprintf("--ctrl-port=%d", ctrlBase),
|
|
fmt.Sprintf("--router-port=%d", routerBase),
|
|
}
|
|
t.Logf("starting: %s %v", zitiPath, args)
|
|
clusterCmd := exec.CommandContext(ctx, zitiPath, args...)
|
|
clusterCmd.Env = append(os.Environ(), "PFXLOG_NO_JSON=true", "ZITI_CONFIG_DIR="+cfgDir)
|
|
clusterCmd.SysProcAttr = newProcessGroupAttr()
|
|
clusterCmd.Stdout = logFile
|
|
clusterCmd.Stderr = logFile
|
|
require.NoError(t, clusterCmd.Start())
|
|
|
|
defer func() {
|
|
if clusterCmd.Process == nil {
|
|
return
|
|
}
|
|
// Backstop in case the test returns before the clean shutdown below.
|
|
if gracefulStop(clusterCmd) != nil {
|
|
forceKillTree(clusterCmd)
|
|
}
|
|
waited := make(chan struct{})
|
|
go func() {
|
|
_, _ = clusterCmd.Process.Wait()
|
|
close(waited)
|
|
}()
|
|
select {
|
|
case <-waited:
|
|
case <-time.After(30 * time.Second):
|
|
}
|
|
}()
|
|
|
|
// Discover the auto-created temp home from the process output.
|
|
reHome := regexp.MustCompile(`temporary --home '([^']+)'`)
|
|
var tempHome string
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) && tempHome == "" {
|
|
data, _ := os.ReadFile(logPath)
|
|
if m := reHome.FindStringSubmatch(string(data)); m != nil {
|
|
tempHome = m[1]
|
|
} else {
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
}
|
|
require.NotEmpty(t, tempHome, "did not observe an auto-created temp home in cluster output (see %s)", logPath)
|
|
t.Logf("cluster temp home: %s", tempHome)
|
|
|
|
// Every node must come up and accept an admin login. Nodes 2 and 3 never run
|
|
// init, so a successful admin login there proves cluster replication.
|
|
for i := 0; i < size; i++ {
|
|
ctrlUrl := fmt.Sprintf("https://localhost:%d", int(ctrlBase)+i)
|
|
require.NoErrorf(t, waitClusterNodeReady(ctrlUrl, "admin", "admin", 180*time.Second),
|
|
"node %d (%s) never became ready; see %s", i+1, ctrlUrl, logPath)
|
|
t.Logf("node %d ready and admin login succeeded at %s", i+1, ctrlUrl)
|
|
}
|
|
|
|
// Drive the cluster through the same ziti CLI that launched it: log into each
|
|
// node, dump and verify raft membership, then prove a model write replicates.
|
|
cliCfg := filepath.Join(t.TempDir(), "test-cli")
|
|
runZiti := func(arg ...string) (stdout, stderr string, err error) {
|
|
c := exec.Command(zitiPath, arg...)
|
|
c.Env = append(os.Environ(), "ZITI_CONFIG_DIR="+cliCfg)
|
|
var so, se bytes.Buffer
|
|
c.Stdout, c.Stderr = &so, &se
|
|
err = c.Run()
|
|
return so.String(), se.String(), err
|
|
}
|
|
login := func(node int) error {
|
|
url := fmt.Sprintf("https://localhost:%d", int(ctrlBase)+node)
|
|
if _, se, err := runZiti("edge", "login", url, "-u", "admin", "-p", "admin", "-y"); err != nil {
|
|
return fmt.Errorf("%w: %s", err, se)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// log in to every node through the CLI
|
|
for i := 0; i < size; i++ {
|
|
require.NoErrorf(t, login(i), "ziti edge login to node %d", i+1)
|
|
}
|
|
|
|
// dump the raft membership for a human to eyeball
|
|
membersOut, membersErr, listErr := runZiti("ops", "cluster", "list")
|
|
require.NoErrorf(t, listErr, "ziti ops cluster list: %s", membersErr)
|
|
t.Logf("ziti ops cluster list:\n%s", membersOut)
|
|
|
|
// verify it: size members, exactly one leader, all connected voters
|
|
require.Eventuallyf(t, func() bool {
|
|
so, _, e := runZiti("ops", "cluster", "list", "-j")
|
|
if e != nil {
|
|
return false
|
|
}
|
|
var resp struct {
|
|
Data []struct {
|
|
Connected bool `json:"connected"`
|
|
Leader bool `json:"leader"`
|
|
Voter bool `json:"voter"`
|
|
} `json:"data"`
|
|
}
|
|
if json.Unmarshal([]byte(so), &resp) != nil || len(resp.Data) != size {
|
|
return false
|
|
}
|
|
leaders, connectedVoters := 0, 0
|
|
for _, m := range resp.Data {
|
|
if m.Leader {
|
|
leaders++
|
|
}
|
|
if m.Connected && m.Voter {
|
|
connectedVoters++
|
|
}
|
|
}
|
|
return leaders == 1 && connectedVoters == size
|
|
}, 30*time.Second, time.Second, "cluster should converge to %d connected voters with one leader", size)
|
|
t.Logf("raft membership verified: %d connected voting members, one leader", size)
|
|
|
|
// prove replication: create an identity on node 1, see it appear on node 2
|
|
idName := fmt.Sprintf("repl-check-%d", time.Now().UnixNano())
|
|
require.NoError(t, login(0))
|
|
if _, createErr, err := runZiti("edge", "create", "identity", idName); err != nil {
|
|
require.NoErrorf(t, err, "create identity on node 1: %s", createErr)
|
|
}
|
|
require.NoError(t, login(1))
|
|
require.Eventuallyf(t, func() bool {
|
|
so, _, e := runZiti("edge", "list", "identities", fmt.Sprintf("name=%q", idName), "-j")
|
|
if e != nil {
|
|
return false
|
|
}
|
|
var resp struct {
|
|
Data []struct {
|
|
Name string `json:"name"`
|
|
} `json:"data"`
|
|
}
|
|
return json.Unmarshal([]byte(so), &resp) == nil && len(resp.Data) == 1 && resp.Data[0].Name == idName
|
|
}, 30*time.Second, time.Second, "identity %q created on node 1 should replicate to node 2", idName)
|
|
t.Logf("identity %q created on node 1 is visible on node 2 (raft replication confirmed)", idName)
|
|
|
|
// Clean shutdown: deliver a graceful stop to the parent. It relays to the
|
|
// children, then removes the temp home.
|
|
if stopErr := gracefulStop(clusterCmd); stopErr != nil {
|
|
forceKillTree(clusterCmd)
|
|
_, _ = clusterCmd.Process.Wait()
|
|
t.Skipf("graceful stop signal not deliverable in this environment (%v); verified 3-node bring-up, skipping shutdown assertions", stopErr)
|
|
}
|
|
exited := make(chan error, 1)
|
|
go func() { exited <- clusterCmd.Wait() }()
|
|
select {
|
|
case <-exited:
|
|
case <-time.After(90 * time.Second):
|
|
t.Fatalf("cluster did not exit within 90s of the stop signal; see %s", logPath)
|
|
}
|
|
|
|
_, statErr := os.Stat(tempHome)
|
|
require.Truef(t, os.IsNotExist(statErr),
|
|
"temp home %s should have been removed after a clean shutdown (stat err: %v)", tempHome, statErr)
|
|
}
|
|
|
|
// waitClusterNodeReady polls until the controller at ctrlUrl serves its CA bundle
|
|
// and accepts an admin UPDB login, or the timeout elapses.
|
|
func waitClusterNodeReady(ctrlUrl, user, pass string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
caCerts, err := rest_util.GetControllerWellKnownCas(ctrlUrl)
|
|
if err != nil {
|
|
lastErr = err
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
pool := x509.NewCertPool()
|
|
for _, ca := range caCerts {
|
|
pool.AddCert(ca)
|
|
}
|
|
if _, err := rest_util.NewEdgeManagementClientWithUpdb(user, pass, ctrlUrl, pool); err != nil {
|
|
lastErr = err
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("not ready within %s: %w", timeout, lastErr)
|
|
}
|
|
|
|
// findConsecutivePorts returns a base port p such that p..p+n-1 are all bindable.
|
|
// Note: there is an inherent TOCTOU race between closing these listeners here and
|
|
// the child processes binding them. This is an accepted limitation of testing
|
|
// with external processes. Under heavy concurrent load a bind can still lose.
|
|
func findConsecutivePorts(t *testing.T, n int) uint16 {
|
|
t.Helper()
|
|
for attempt := 0; attempt < 100; attempt++ {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
base := l.Addr().(*net.TCPAddr).Port
|
|
_ = l.Close()
|
|
if base == 0 || base+n-1 > 65535 {
|
|
continue
|
|
}
|
|
held := make([]net.Listener, 0, n)
|
|
ok := true
|
|
for i := 0; i < n; i++ {
|
|
li, e := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", base+i))
|
|
if e != nil {
|
|
ok = false
|
|
break
|
|
}
|
|
held = append(held, li)
|
|
}
|
|
for _, li := range held {
|
|
_ = li.Close()
|
|
}
|
|
if ok {
|
|
return uint16(base)
|
|
}
|
|
}
|
|
t.Fatalf("could not find %d consecutive free ports", n)
|
|
return 0
|
|
}
|