| package test | 
 |  | 
 | import ( | 
 | 	"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. | 
 | 			ob, err := os.ReadFile("describe.txt") | 
 | 			if err != nil { | 
 | 				return fmt.Errorf("while reading metroctl output: %v", err) | 
 | 			} | 
 | 			var onid, ostate, onaddr, onstatus, onroles string | 
 | 			var ontimeout int | 
 | 			_, err = fmt.Sscanf(string(ob[:]), "%s\t%s\t%s\t%s\t%s\t%ds\n", &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 != "NODE_STATE_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("set/unset role", func(t *testing.T) { | 
 | 		util.TestEventual(t, "metroctl set/unset role KubernetesController", 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 KubernetesController [NodeID, ...]". | 
 |  | 
 | 			// Check that KubernetesController role is set 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 := mctlFailIfMissing(t, ctx, describeArgs, "KubernetesController"); err != nil { | 
 | 				return err | 
 | 			} | 
 | 			// Remove the role. | 
 | 			var unsetArgs []string | 
 | 			unsetArgs = append(unsetArgs, commonOpts...) | 
 | 			unsetArgs = append(unsetArgs, endpointOpts...) | 
 | 			unsetArgs = append(unsetArgs, "node", "remove", "role", "KubernetesController", nid) | 
 | 			if err := mctlRun(t, ctx, unsetArgs); err != nil { | 
 | 				return err | 
 | 			} | 
 | 			// Check that the role is unset. | 
 | 			if err := mctlFailIfFound(t, ctx, describeArgs, "KubernetesController"); err != nil { | 
 | 				return err | 
 | 			} | 
 | 			// Set the role back to the initial value. | 
 | 			var setArgs []string | 
 | 			setArgs = append(setArgs, commonOpts...) | 
 | 			setArgs = append(setArgs, endpointOpts...) | 
 | 			setArgs = append(setArgs, "node", "add", "role", "KubernetesController", nid) | 
 | 			if err := mctlRun(t, ctx, setArgs); err != nil { | 
 | 				return err | 
 | 			} | 
 | 			// Chack that the role is set. | 
 | 			return mctlFailIfMissing(t, ctx, describeArgs, "KubernetesController") | 
 | 		}) | 
 | 	}) | 
 | } |