| package test |
| |
| import ( |
| "bufio" |
| "context" |
| "encoding/pem" |
| "fmt" |
| "log" |
| "os" |
| "strings" |
| "testing" |
| "time" |
| |
| "source.monogon.dev/metropolis/cli/pkg/datafile" |
| "source.monogon.dev/metropolis/pkg/cmd" |
| "source.monogon.dev/metropolis/test/launch/cluster" |
| "source.monogon.dev/metropolis/test/util" |
| ) |
| |
| // resolveMetroctl resolves metroctl filesystem path. It will return a correct |
| // path, or terminate test execution. |
| func resolveMetroctl() string { |
| path, err := datafile.ResolveRunfile("metropolis/cli/metroctl/metroctl_/metroctl") |
| if err != nil { |
| log.Fatalf("Couldn't resolve metroctl binary: %v", err) |
| } |
| return path |
| } |
| |
| // mctlRun starts metroctl, and waits till it exits. It returns nil, if the run |
| // was successful. |
| func mctlRun(t *testing.T, ctx context.Context, args []string) error { |
| t.Helper() |
| |
| path := resolveMetroctl() |
| log.Printf("$ metroctl %s", strings.Join(args, " ")) |
| logf := func(line string) { |
| log.Printf("metroctl: %s", line) |
| } |
| _, err := cmd.RunCommand(ctx, path, args, cmd.WaitUntilCompletion(logf)) |
| return err |
| } |
| |
| // mctlExpectOutput returns true in the event the expected string is found in |
| // metroctl output, and false otherwise. |
| func mctlExpectOutput(t *testing.T, ctx context.Context, args []string, expect string) (bool, error) { |
| t.Helper() |
| |
| path := resolveMetroctl() |
| log.Printf("$ metroctl %s", strings.Join(args, " ")) |
| // Terminate metroctl as soon as the expected output is found. |
| logf := func(line string) { |
| log.Printf("metroctl: %s", line) |
| } |
| found, err := cmd.RunCommand(ctx, path, args, cmd.TerminateIfFound(expect, logf)) |
| if err != nil { |
| return false, fmt.Errorf("while running metroctl: %v", err) |
| } |
| return found, nil |
| } |
| |
| // mctlFailIfMissing will return a non-nil error value either if the expected |
| // output string s is missing in metroctl output, or in case metroctl can't be |
| // launched. |
| func mctlFailIfMissing(t *testing.T, ctx context.Context, args []string, s string) error { |
| found, err := mctlExpectOutput(t, ctx, args, s) |
| if err != nil { |
| return err |
| } |
| if !found { |
| return fmt.Errorf("expected output is missing: \"%s\"", s) |
| } |
| return nil |
| } |
| |
| // mctlFailIfFound will return a non-nil error value either if the expected |
| // output string s is found in metroctl output, or in case metroctl can't be |
| // launched. |
| func mctlFailIfFound(t *testing.T, ctx context.Context, args []string, s string) error { |
| found, err := mctlExpectOutput(t, ctx, args, s) |
| if err != nil { |
| return err |
| } |
| if found { |
| return fmt.Errorf("unexpected output was found: \"%s\"", s) |
| } |
| return nil |
| } |
| |
| func TestMetroctl(t *testing.T) { |
| ctx, ctxC := context.WithCancel(context.Background()) |
| defer ctxC() |
| |
| co := cluster.ClusterOptions{ |
| NumNodes: 2, |
| } |
| cl, err := cluster.LaunchCluster(context.Background(), co) |
| if err != nil { |
| t.Fatalf("LaunchCluster failed: %v", err) |
| } |
| defer func() { |
| err := cl.Close() |
| if err != nil { |
| t.Fatalf("cluster Close failed: %v", err) |
| } |
| }() |
| |
| socksRemote := fmt.Sprintf("localhost:%d", cl.Ports[cluster.SOCKSPort]) |
| var clusterEndpoints []string |
| for _, ep := range cl.Nodes { |
| clusterEndpoints = append(clusterEndpoints, ep.ManagementAddress) |
| } |
| |
| ownerPem := pem.EncodeToMemory(&pem.Block{ |
| Type: "METROPOLIS INITIAL OWNER PRIVATE KEY", |
| Bytes: cluster.InsecurePrivateKey, |
| }) |
| if err := os.WriteFile("owner-key.pem", ownerPem, 0644); err != nil { |
| log.Fatal("Couldn't write owner-key.pem") |
| } |
| |
| commonOpts := []string{ |
| "--proxy=" + socksRemote, |
| "--config=.", |
| } |
| |
| var endpointOpts []string |
| for _, ep := range clusterEndpoints { |
| endpointOpts = append(endpointOpts, "--endpoints="+ep) |
| } |
| |
| log.Printf("metroctl: Cluster's running, starting tests...") |
| st := t.Run("Init", func(t *testing.T) { |
| util.TestEventual(t, "metroctl takeownership", ctx, 30*time.Second, func(ctx context.Context) error { |
| // takeownership needs just a single endpoint pointing at the initial node. |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts[0]) |
| args = append(args, "takeownership") |
| return mctlFailIfMissing(t, ctx, args, "Successfully retrieved owner credentials") |
| }) |
| }) |
| if !st { |
| t.Fatalf("metroctl: Couldn't get cluster ownership.") |
| } |
| t.Run("list", func(t *testing.T) { |
| util.TestEventual(t, "metroctl list", ctx, 10*time.Second, func(ctx context.Context) error { |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| args = append(args, "node", "list") |
| // Expect both node IDs to show up in the results. |
| if err := mctlFailIfMissing(t, ctx, args, cl.NodeIDs[0]); err != nil { |
| return err |
| } |
| return mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1]) |
| }) |
| }) |
| t.Run("list [nodeID]", func(t *testing.T) { |
| util.TestEventual(t, "metroctl list [nodeID]", ctx, 10*time.Second, func(ctx context.Context) error { |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| args = append(args, "node", "list", cl.NodeIDs[1]) |
| // Expect just the supplied node IDs to show up in the results. |
| if err := mctlFailIfFound(t, ctx, args, cl.NodeIDs[0]); err != nil { |
| return err |
| } |
| return mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1]) |
| }) |
| }) |
| t.Run("list --output", func(t *testing.T) { |
| util.TestEventual(t, "metroctl list --output", ctx, 10*time.Second, func(ctx context.Context) error { |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| args = append(args, "node", "list", "--output", "list.txt") |
| // In this case metroctl should write its output to a file rather than stdout. |
| if err := mctlFailIfFound(t, ctx, args, cl.NodeIDs[0]); err != nil { |
| return err |
| } |
| od, err := os.ReadFile("list.txt") |
| if err != nil { |
| return fmt.Errorf("while reading metroctl output file: %v", err) |
| } |
| if !strings.Contains(string(od), cl.NodeIDs[0]) { |
| return fmt.Errorf("expected node ID hasn't been found in metroctl output") |
| } |
| return nil |
| }) |
| }) |
| t.Run("list --filter", func(t *testing.T) { |
| util.TestEventual(t, "metroctl list --filter", ctx, 10*time.Second, func(ctx context.Context) error { |
| nid := cl.NodeIDs[1] |
| naddr := cl.Nodes[nid].ManagementAddress |
| |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| // Filter list results based on nodes' external addresses. |
| args = append(args, "node", "list", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr)) |
| // Expect the second node's ID to show up in the results. |
| if err := mctlFailIfMissing(t, ctx, args, cl.NodeIDs[1]); err != nil { |
| return err |
| } |
| // The first node should've been filtered away. |
| return mctlFailIfFound(t, ctx, args, cl.NodeIDs[0]) |
| }) |
| }) |
| t.Run("describe --filter", func(t *testing.T) { |
| util.TestEventual(t, "metroctl list --filter", ctx, 10*time.Second, func(ctx context.Context) error { |
| nid := cl.NodeIDs[0] |
| naddr := cl.Nodes[nid].ManagementAddress |
| |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| |
| // Filter out the first node. Afterwards, only one node should be left. |
| args = append(args, "node", "describe", "--output", "describe.txt", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr)) |
| if err := mctlRun(t, ctx, args); err != nil { |
| return err |
| } |
| |
| // Try matching metroctl output against the advertised format. |
| f, err := os.Open("describe.txt") |
| if err != nil { |
| return fmt.Errorf("while opening metroctl output: %v", err) |
| } |
| scanner := bufio.NewScanner(f) |
| if !scanner.Scan() { |
| return fmt.Errorf("expected header line") |
| } |
| if !scanner.Scan() { |
| return fmt.Errorf("expected result line") |
| } |
| line := scanner.Text() |
| t.Logf("Line: %q", line) |
| |
| var onid, ostate, onaddr, onstatus, onroles string |
| var ontimeout int |
| |
| _, err = fmt.Sscanf(line, "%s%s%s%s%s%ds", &onid, &ostate, &onaddr, &onstatus, &onroles, &ontimeout) |
| if err != nil { |
| return fmt.Errorf("while parsing metroctl output: %v", err) |
| } |
| if onid != nid { |
| return fmt.Errorf("node id mismatch") |
| } |
| if ostate != "UP" { |
| return fmt.Errorf("node state mismatch") |
| } |
| if onaddr != naddr { |
| return fmt.Errorf("node address mismatch") |
| } |
| if onstatus != "HEALTHY" { |
| return fmt.Errorf("node status mismatch") |
| } |
| if want, got := "ConsensusMember,KubernetesController", onroles; want != got { |
| return fmt.Errorf("node role mismatch: wanted %q, got %q", want, got) |
| } |
| if ontimeout < 0 || ontimeout > 30 { |
| return fmt.Errorf("node timeout mismatch") |
| } |
| return nil |
| }) |
| }) |
| t.Run("logs [nodeID]", func(t *testing.T) { |
| util.TestEventual(t, "metroctl logs [nodeID]", ctx, 10*time.Second, func(ctx context.Context) error { |
| var args []string |
| args = append(args, commonOpts...) |
| args = append(args, endpointOpts...) |
| args = append(args, "node", "logs", cl.NodeIDs[1]) |
| |
| if err := mctlFailIfMissing(t, ctx, args, "Cluster enrolment done."); err != nil { |
| return err |
| } |
| |
| return nil |
| }) |
| }) |
| t.Run("set/unset role", func(t *testing.T) { |
| util.TestEventual(t, "metroctl set/unset role KubernetesWorker", ctx, 10*time.Second, func(ctx context.Context) error { |
| nid := cl.NodeIDs[1] |
| naddr := cl.Nodes[nid].ManagementAddress |
| |
| // In this test we'll unset a node role, make sure that it's been in fact |
| // unset, then set it again, and check again. This exercises commands of |
| // the form "metroctl set/unset role KubernetesWorker [NodeID, ...]". |
| |
| // Check that KubernetesWorker role is absent initially. |
| var describeArgs []string |
| describeArgs = append(describeArgs, commonOpts...) |
| describeArgs = append(describeArgs, endpointOpts...) |
| describeArgs = append(describeArgs, "node", "describe", "--filter", fmt.Sprintf("node.status.external_address==\"%s\"", naddr)) |
| if err := mctlFailIfFound(t, ctx, describeArgs, "KubernetesWorker"); err != nil { |
| return err |
| } |
| // Add the role. |
| var setArgs []string |
| setArgs = append(setArgs, commonOpts...) |
| setArgs = append(setArgs, endpointOpts...) |
| setArgs = append(setArgs, "node", "add", "role", "KubernetesWorker", nid) |
| if err := mctlRun(t, ctx, setArgs); err != nil { |
| return err |
| } |
| // Check that the role is set. |
| if err := mctlFailIfMissing(t, ctx, describeArgs, "KubernetesWorker"); err != nil { |
| return err |
| } |
| |
| // Remove the role back to the initial value. |
| var unsetArgs []string |
| unsetArgs = append(unsetArgs, commonOpts...) |
| unsetArgs = append(unsetArgs, endpointOpts...) |
| unsetArgs = append(unsetArgs, "node", "remove", "role", "KubernetesWorker", nid) |
| if err := mctlRun(t, ctx, unsetArgs); err != nil { |
| return err |
| } |
| // Check that the role is unset. |
| if err := mctlFailIfFound(t, ctx, describeArgs, "KubernetesWorker"); err != nil { |
| return err |
| } |
| |
| return nil |
| }) |
| }) |
| } |