|
| 1 | +//go:build e2e |
| 2 | + |
| 3 | +package e2e |
| 4 | + |
| 5 | +// external_cp_test.go proves design principle 7 / issue #4099-5: a node joins a |
| 6 | +// control plane the provider did NOT bootstrap, using operator-supplied trust |
| 7 | +// anchors. |
| 8 | +// |
| 9 | +// This is distinct from TestWorkerJoin (scenarios_test.go), where the CP is |
| 10 | +// provider-init'd and the join material comes from `mint-join`. Here the CP is |
| 11 | +// brought up by PLAIN `kubeadm init` -- the provider never runs on it, so it is a |
| 12 | +// genuinely externally-managed control plane. The operator then hands the worker |
| 13 | +// only: |
| 14 | +// - the CP's CA certificate in PEM form (cluster.ca_certs), and |
| 15 | +// - a bootstrap token (kubeadm token create), and the endpoint. |
| 16 | +// |
| 17 | +// Crucially the worker config carries NO caCertHashes. The provider must DERIVE |
| 18 | +// the SPKI pin from the operator-supplied CA PEM (resolveCACertHashes, OQ-9/ADR-10) |
| 19 | +// and join with CA pinning -- never UnsafeSkipCAVerification. A successful join |
| 20 | +// with only the PEM supplied is the proof that the derive-and-pin path works |
| 21 | +// end-to-end against a real CA, not just in unit tests. |
| 22 | + |
| 23 | +import ( |
| 24 | + "strings" |
| 25 | + "testing" |
| 26 | + "time" |
| 27 | + |
| 28 | + "github.com/kairos-io/kairos-sdk/clusterplugin" |
| 29 | +) |
| 30 | + |
| 31 | +// externalKubeadmInit brings up a single-node control plane WITHOUT the provider: |
| 32 | +// it runs `kubeadm init` directly in the container with plain flags. The provider |
| 33 | +// binary is never invoked here, so the resulting CP is "externally managed" from |
| 34 | +// the provider's point of view. Waits for the apiserver to be healthy. |
| 35 | +func externalKubeadmInit(t *testing.T, nc *nodeContainer, ip, k8sVer string) { |
| 36 | + t.Helper() |
| 37 | + out, err := nc.ExecTimeout(reconcileTimeout, |
| 38 | + "kubeadm", "init", |
| 39 | + "--apiserver-advertise-address", ip, |
| 40 | + "--pod-network-cidr", "10.244.0.0/16", |
| 41 | + "--kubernetes-version", k8sVer, |
| 42 | + "--cri-socket", "unix:///run/containerd/containerd.sock", |
| 43 | + ) |
| 44 | + if err != nil { |
| 45 | + t.Fatalf("plain kubeadm init (external CP): %v\n--- output ---\n%s", err, out) |
| 46 | + } |
| 47 | + nc.waitFor(t, "external CP apiserver /healthz ok", 120*time.Second, func() bool { |
| 48 | + o, e := nc.execErr("kubectl", "--kubeconfig", adminConf, "get", "--raw", "/healthz") |
| 49 | + return e == nil && strings.TrimSpace(o) == "ok" |
| 50 | + }) |
| 51 | +} |
| 52 | + |
| 53 | +// TestExternallyManagedControlPlaneJoin (design principle 7 / #4099-5): a worker |
| 54 | +// joins a control plane the provider did not create, with the provider deriving |
| 55 | +// the CA pin from an operator-supplied CA PEM. |
| 56 | +// |
| 57 | +// Flow: |
| 58 | +// 1. Start the CP container; bring up the CP with PLAIN kubeadm init (no provider). |
| 59 | +// 2. Operator extracts trust anchors out-of-band: the CA cert PEM |
| 60 | +// (/etc/kubernetes/pki/ca.crt) and a fresh bootstrap token (kubeadm token |
| 61 | +// create). NO SPKI hash is computed by the operator. |
| 62 | +// 3. Build a role=worker Cluster supplying ONLY ca_certs (the PEM) + the token + |
| 63 | +// endpoint -- no caCertHashes. The provider must derive the pin from the PEM. |
| 64 | +// 4. Start a second container (worker), reconcile via the provider. |
| 65 | +// 5. Assert: worker converges/joined; the externally-managed CP shows 2 nodes; |
| 66 | +// and the CP never had the provider run on it (no provider status file) -- |
| 67 | +// confirming it is genuinely externally managed. |
| 68 | +func TestExternallyManagedControlPlaneJoin(t *testing.T) { |
| 69 | + k8sVer := kubernetesVersion() |
| 70 | + |
| 71 | + // 1. Externally-managed CP: plain kubeadm init, provider never involved. |
| 72 | + cpNC := startNode(t, uniqueName("ext-cp")) |
| 73 | + cpIP := cpNC.IP(t) |
| 74 | + prepullControlPlaneImages(t, cpNC, k8sVer) |
| 75 | + externalKubeadmInit(t, cpNC, cpIP, k8sVer) |
| 76 | + |
| 77 | + // Sanity: the CP is externally managed -- the provider never ran on it, so it |
| 78 | + // has no provider status file. This distinguishes the scenario from TestWorkerJoin. |
| 79 | + if _, statErr := cpNC.execErr("test", "-e", statusRunPath); statErr == nil { |
| 80 | + t.Fatalf("external CP unexpectedly has a provider status file at %s; it should be externally managed (provider never ran on it)", statusRunPath) |
| 81 | + } |
| 82 | + |
| 83 | + // 2. Operator extracts trust anchors out-of-band. |
| 84 | + caPEM, err := cpNC.ReadFile("/etc/kubernetes/pki/ca.crt") |
| 85 | + if err != nil { |
| 86 | + t.Fatalf("read external CP ca.crt: %v\n%s", err, caPEM) |
| 87 | + } |
| 88 | + if !strings.Contains(caPEM, "BEGIN CERTIFICATE") { |
| 89 | + t.Fatalf("external CP ca.crt does not look like a PEM cert:\n%s", caPEM) |
| 90 | + } |
| 91 | + tokenOut, err := cpNC.ExecTimeout(60*time.Second, "kubeadm", "token", "create") |
| 92 | + if err != nil { |
| 93 | + t.Fatalf("kubeadm token create on external CP: %v\n%s", err, tokenOut) |
| 94 | + } |
| 95 | + token := strings.TrimSpace(tokenOut) |
| 96 | + if token == "" { |
| 97 | + t.Fatal("kubeadm token create returned an empty token") |
| 98 | + } |
| 99 | + cpEndpoint := cpIP + ":6443" |
| 100 | + t.Logf("external CP up at %s; operator extracted CA PEM (%d bytes) + bootstrap token", cpEndpoint, len(caPEM)) |
| 101 | + |
| 102 | + // 3. Worker Cluster: supply ONLY the CA PEM (ca_certs) + token + endpoint. |
| 103 | + // No caCertHashes -- the provider derives the SPKI pin from the PEM |
| 104 | + // (resolveCACertHashes). CA pinning is mandatory; UnsafeSkipCAVerification is |
| 105 | + // never set. |
| 106 | + workerCluster := clusterplugin.Cluster{ |
| 107 | + Role: clusterplugin.Role(clusterplugin.RoleWorker), |
| 108 | + ClusterToken: randomToken(t), |
| 109 | + ControlPlaneHost: cpEndpoint, |
| 110 | + CACerts: []string{caPEM}, |
| 111 | + ProviderOptions: map[string]string{"cluster_root_path": "/"}, |
| 112 | + Options: strings.Join([]string{ |
| 113 | + "joinConfiguration:", |
| 114 | + " discovery:", |
| 115 | + " bootstrapToken:", |
| 116 | + " token: " + token, |
| 117 | + " apiServerEndpoint: " + cpEndpoint, |
| 118 | + // Deliberately NO caCertHashes: the provider must derive the pin from |
| 119 | + // ca_certs (the operator-supplied PEM above). |
| 120 | + }, "\n") + "\n", |
| 121 | + } |
| 122 | + |
| 123 | + // 4. Worker container joins the external CP via the provider. |
| 124 | + workerNC := startNode(t, uniqueName("ext-worker")) |
| 125 | + prepullControlPlaneImages(t, workerNC, k8sVer) |
| 126 | + |
| 127 | + workerOut, workerErr := writeClusterAndReconcile(t, workerNC, workerCluster) |
| 128 | + if workerErr != nil { |
| 129 | + t.Fatalf("worker reconcile joining external CP failed: %v\n--- output ---\n%s", workerErr, workerOut) |
| 130 | + } |
| 131 | + t.Logf("worker reconcile output:\n%s", workerOut) |
| 132 | + |
| 133 | + // 5a. Worker status: converged + joined. |
| 134 | + ws := readStatus(t, workerNC) |
| 135 | + if ws.Phase != "Converged" { |
| 136 | + t.Errorf("worker status phase = %q, want Converged (full: %+v)", ws.Phase, ws) |
| 137 | + } |
| 138 | + if ws.Outcome != "success" { |
| 139 | + t.Errorf("worker status outcome = %q, want success", ws.Outcome) |
| 140 | + } |
| 141 | + if ws.Membership != "joined" { |
| 142 | + t.Errorf("worker status membership = %q, want joined", ws.Membership) |
| 143 | + } |
| 144 | + |
| 145 | + // 5b. The externally-managed CP now sees 2 registered nodes. |
| 146 | + var nodeCount int |
| 147 | + cpNC.waitFor(t, "2 nodes registered on external CP", 90*time.Second, func() bool { |
| 148 | + o, e := cpNC.execErr("kubectl", "--kubeconfig", adminConf, |
| 149 | + "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}") |
| 150 | + if e != nil { |
| 151 | + return false |
| 152 | + } |
| 153 | + names := strings.Fields(strings.TrimSpace(o)) |
| 154 | + nodeCount = len(names) |
| 155 | + t.Logf("nodes registered on external CP: %v", names) |
| 156 | + return nodeCount >= 2 |
| 157 | + }) |
| 158 | + if nodeCount < 2 { |
| 159 | + t.Errorf("expected 2 registered nodes on the external CP after join, got %d", nodeCount) |
| 160 | + } |
| 161 | +} |
0 commit comments