Skip to content

Commit c8f49f1

Browse files
shenpai35copybara-github
authored andcommitted
Refactor Metadata Server (MDS) hosts setup to support IPv6 MDS hostname resolution in guest configs and introduce comprehensive CIT test coverage.
PiperOrigin-RevId: 925530824
1 parent d6e8416 commit c8f49f1

3 files changed

Lines changed: 288 additions & 1 deletion

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright 2026 Google LLC.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build linux
16+
17+
package nicsetup
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
"strings"
24+
"testing"
25+
"time"
26+
)
27+
28+
// TestMetadataHostsCompliance validates that /etc/hosts is correctly configured
29+
// based on the stack types detected on the primary interface (nic0).
30+
func TestMetadataHostsCompliance(t *testing.T) {
31+
t.Logf("%s: Verifying /etc/hosts metadata compliance", getCurrentTime())
32+
33+
hasGlobalV4, hasGlobalV6 := getStackType(t)
34+
t.Logf("Detected stack type: Global IPv4: %v, Global IPv6: %v", hasGlobalV4, hasGlobalV6)
35+
36+
hostsBytes, err := os.ReadFile("/etc/hosts")
37+
if err != nil {
38+
t.Fatalf("failed to read /etc/hosts: %v", err)
39+
}
40+
hostsContent := string(hostsBytes)
41+
t.Logf("/etc/hosts content:\n%s", hostsContent)
42+
43+
hasV4MDS := strings.Contains(hostsContent, "169.254.169.254 metadata.google.internal")
44+
hasV6MDS := strings.Contains(hostsContent, "fd20:ce::254 metadata.google.internal")
45+
46+
dualStack := hasGlobalV4 && hasGlobalV6
47+
ipv4Only := hasGlobalV4 && !hasGlobalV6
48+
ipv6Only := !hasGlobalV4 && hasGlobalV6
49+
stackName := "Unknown"
50+
51+
if dualStack {
52+
stackName = "DualStack"
53+
if !hasV4MDS {
54+
t.Errorf("%s: MUST contain 169.254.169.254 metadata.google.internal", stackName)
55+
}
56+
if hasV6MDS {
57+
t.Errorf("%s: MUST NOT contain fd20:ce::254 metadata.google.internal (IPv6 MDS disabled on dual-stack)", stackName)
58+
}
59+
} else if ipv6Only {
60+
stackName = "IPv6Only"
61+
if !hasV6MDS {
62+
t.Errorf("%s: MUST contain fd20:ce::254 metadata.google.internal", stackName)
63+
}
64+
} else if ipv4Only {
65+
stackName = "IPv4Only"
66+
if !hasV4MDS {
67+
t.Errorf("%s: MUST contain 169.254.169.254 metadata.google.internal", stackName)
68+
}
69+
if hasV6MDS {
70+
t.Errorf("%s: MUST NOT contain fd20:ce::254 metadata.google.internal", stackName)
71+
}
72+
} else {
73+
stackName = "NoGlobalIP"
74+
if hasV4MDS || hasV6MDS {
75+
t.Errorf("%s: unexpected MDS entries found", stackName)
76+
}
77+
}
78+
t.Logf("Detected stack type: %s", stackName)
79+
}
80+
81+
// TestMetadataResolutionAndReachability verifies MDS reachability & content specifically on nic0.
82+
func TestMetadataResolutionAndReachability(t *testing.T) {
83+
t.Logf("%s: Verifying MDS reachability & content via hostname", getCurrentTime())
84+
85+
hasGlobalV4, hasGlobalV6 := getStackType(t)
86+
if !hasGlobalV4 && !hasGlobalV6 {
87+
t.Skip("Skipping reachability tests: No global IP addresses detected on primary interface.")
88+
return
89+
}
90+
91+
curlCmd := exec.Command("curl", "-sSf", "-m", "5", "-H", "Metadata-Flavor: Google", "http://metadata.google.internal/computeMetadata/v1/instance/hostname")
92+
output, err := curlCmd.CombinedOutput()
93+
94+
if err != nil {
95+
t.Fatalf("MDS reachability via HOSTNAME failed (Stack: v4:%v, v6:%v): %v\nOutput: %s", hasGlobalV4, hasGlobalV6, err, string(output))
96+
} else {
97+
t.Logf("MDS hostname resolution and reachability successful: %s, detected stack: v4:%v, v6:%v", strings.TrimSpace(string(output)), hasGlobalV4, hasGlobalV6)
98+
}
99+
}
100+
101+
// TestMDSHostsCorrection executes a negative test on Dual-Stack VMs by manually
102+
// injecting a forbidden IPv6 MDS entry, triggering the network dispatcher, and
103+
// verifying that the core worker successfully scrubs the file.
104+
func TestMDSHostsCorrection(t *testing.T) {
105+
t.Logf("%s: Verifying /etc/hosts correction on dual stack (Negative Test)", getCurrentTime())
106+
107+
hasGlobalV4, hasGlobalV6 := getStackType(t)
108+
if !(hasGlobalV4 && hasGlobalV6) {
109+
t.Skip("Skipping negative test: Not a dual-stack VM.")
110+
return
111+
}
112+
113+
badEntry := "fd20:ce::254 metadata.google.internal"
114+
hostsFile := "/etc/hosts"
115+
116+
// Append the forbidden IPv6 entry to /etc/hosts
117+
hostsBytes, _ := os.ReadFile(hostsFile)
118+
originalContent := string(hostsBytes)
119+
if !strings.Contains(originalContent, badEntry) {
120+
f, err := os.OpenFile(hostsFile, os.O_APPEND|os.O_WRONLY, 0644)
121+
if err != nil {
122+
t.Fatalf("Failed to open /etc/hosts for append: %v", err)
123+
}
124+
if _, err := f.WriteString("\n" + badEntry + "\n"); err != nil {
125+
t.Fatalf("Failed to write bad entry to /etc/hosts: %v", err)
126+
}
127+
f.Close()
128+
t.Logf("Appended forbidden IPv6 entry to /etc/hosts")
129+
}
130+
131+
// Restart the network service (Triggers dispatcher hooks)
132+
refreshNetworkState(t)
133+
134+
// Wait for the asynchronous script execution to acquire lock and complete
135+
time.Sleep(5 * time.Second)
136+
137+
// Verify the forbidden line was removed
138+
hostsBytes, _ = os.ReadFile(hostsFile)
139+
currentContent := string(hostsBytes)
140+
141+
if strings.Contains(currentContent, badEntry) {
142+
t.Errorf("Negative Test Failed: Dispatcher script failed to remove the forbidden IPv6 MDS entry.")
143+
} else {
144+
t.Logf("Negative Test Passed: Forbidden entry successfully scrubbed.")
145+
}
146+
147+
// Ensure the required IPv4 entry survived
148+
if !strings.Contains(currentContent, "169.254.169.254 metadata.google.internal") {
149+
t.Errorf("Negative Test Failed: Dispatcher script accidentally removed the required IPv4 MDS entry during cleanup.")
150+
}
151+
}
152+
153+
// refreshNetworkState detects the active network service and forces a
154+
// re-run of dispatcher scripts to verify configuration enforcement.
155+
func refreshNetworkState(t *testing.T) {
156+
t.Helper()
157+
158+
// Detect primary interface dynamically
159+
outIface, _ := exec.Command("bash", "-c", "ip route show default | head -n 1 | awk '{print $5}'").Output()
160+
primaryIface := strings.TrimSpace(string(outIface))
161+
if primaryIface == "" {
162+
outIface6, _ := exec.Command("bash", "-c", "ip -6 route show default | head -n 1 | awk '{print $5}'").Output()
163+
primaryIface = strings.TrimSpace(string(outIface6))
164+
}
165+
if primaryIface == "" {
166+
t.Errorf("Failed to detect primary interface")
167+
return
168+
}
169+
170+
// Check for systemd-networkd (Primary for Debian 12+/Ubuntu)
171+
_, errNetworkd := exec.LookPath("networkd-dispatcher")
172+
isNetworkdActive := exec.Command("systemctl", "is-active", "--quiet", "systemd-networkd").Run() == nil
173+
174+
if errNetworkd == nil && isNetworkdActive {
175+
t.Log("Detected systemd-networkd; triggering via networkd-dispatcher")
176+
cmd := exec.Command("sudo", "networkd-dispatcher", "--run-startup-triggers")
177+
if err := cmd.Start(); err != nil {
178+
t.Errorf("networkd-dispatcher trigger failed to start: %v", err)
179+
}
180+
return
181+
}
182+
183+
// Check for NetworkManager (Primary for RHEL/CentOS/Fedora)
184+
_, errNM := exec.LookPath("nmcli")
185+
isNMActive := exec.Command("systemctl", "is-active", "--quiet", "NetworkManager").Run() == nil
186+
187+
if errNM == nil && isNMActive {
188+
t.Logf("Detected NetworkManager; simulating dispatcher event for %s", primaryIface)
189+
190+
hookPath := "/etc/NetworkManager/dispatcher.d/google_hostname.sh"
191+
192+
// Verify the hook exists before calling it
193+
if _, err := os.Stat(hookPath); err == nil {
194+
// Call the hook exactly as NM does: script <interface> <action>
195+
cmd := exec.Command("sudo", hookPath, primaryIface, "up")
196+
if err := cmd.Start(); err != nil {
197+
t.Errorf("Simulated NetworkManager hook failed to start: %v", err)
198+
}
199+
return
200+
}
201+
}
202+
203+
// Check for dhclient
204+
isDhclientActive := exec.Command("pgrep", "dhclient").Run() == nil
205+
206+
if isDhclientActive {
207+
t.Logf("Detected active dhclient; forcing release and restart on %s", primaryIface)
208+
209+
// Release the lease properly
210+
_ = exec.Command("sudo", "dhclient", "-r", primaryIface).Run()
211+
212+
// Kill any rogue/zombie dhclient processes left behind
213+
// This is needed to ensure that the next dhclient process is the only one running.
214+
_ = exec.Command("sudo", "pkill", "-9", "dhclient").Run()
215+
216+
// Bring the interface back up cleanly using the system's native network manager
217+
// rather than launching a raw dhclient process.
218+
restartCmd := exec.Command("sudo", "ifup", "--force", primaryIface)
219+
if err := restartCmd.Start(); err != nil {
220+
t.Logf("Warning: ifup failed, falling back to raw dhclient start: %v", err)
221+
_ = exec.Command("sudo", "dhclient", primaryIface).Start()
222+
}
223+
return
224+
}
225+
// Check for wicked (Primary for SUSE/SLES)
226+
_, errWicked := exec.LookPath("wicked")
227+
isWickedActive := exec.Command("systemctl", "is-active", "--quiet", "wicked").Run() == nil
228+
229+
if errWicked == nil && isWickedActive {
230+
t.Logf("Detected wicked; simulating post-up hook for %s", primaryIface)
231+
232+
hookPath := "/etc/sysconfig/network/scripts/google_up.sh"
233+
234+
if _, err := os.Stat(hookPath); err == nil {
235+
cmd := exec.Command("sudo", hookPath, primaryIface)
236+
if err := cmd.Start(); err != nil {
237+
t.Errorf("Simulated wicked hook failed to start: %v", err)
238+
}
239+
return
240+
}
241+
t.Errorf("wicked is active, but hook script is missing at %s", hookPath)
242+
return
243+
}
244+
245+
// Update the fatal string to include dhclient
246+
t.Fatalf("No active network manager (systemd-networkd, NetworkManager, or dhclient) found to trigger dispatcher scripts.")
247+
}
248+
249+
// getStackType determines the IP stack capabilities of the PRIMARY interface only,
250+
// as Google Cloud Metadata Server (MDS) routing is strictly bound to nic0.
251+
func getStackType(t *testing.T) (v4 bool, v6 bool) {
252+
t.Helper()
253+
t.Logf("%s: Determining stack type for primary interface", getCurrentTime())
254+
255+
// Identify the primary interface (checking both IPv4 and IPv6 routes)
256+
outIface, _ := exec.Command("bash", "-c", "ip route show default | head -n 1 | awk '{print $5}'").Output()
257+
primaryIface := strings.TrimSpace(string(outIface))
258+
if primaryIface == "" {
259+
outIface6, _ := exec.Command("bash", "-c", "ip -6 route show default | head -n 1 | awk '{print $5}'").Output()
260+
primaryIface = strings.TrimSpace(string(outIface6))
261+
}
262+
if primaryIface == "" {
263+
primaryIface = "eth0" // Fallback
264+
}
265+
266+
t.Logf("Evaluating stack type specifically for primary interface: %s", primaryIface)
267+
268+
// Check for global IPv4 exclusively on the primary interface
269+
cmdV4 := fmt.Sprintf("ip -4 addr show dev %s scope global | grep -v '169.254' | grep -q 'inet '", primaryIface)
270+
if err := exec.Command("bash", "-c", cmdV4).Run(); err == nil {
271+
v4 = true
272+
}
273+
274+
// Check for global IPv6 exclusively on the primary interface
275+
cmdV6 := fmt.Sprintf("ip -6 addr show dev %s scope global | grep -q 'inet6'", primaryIface)
276+
if err := exec.Command("bash", "-c", cmdV6).Run(); err == nil {
277+
v6 = true
278+
}
279+
280+
return v4, v6
281+
}

test_suites/nicsetup/nicsetup_linux_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ const (
6969
// pingVMPort is the port to listen on for the ping VM. This is chosen
7070
// arbitrarily to avoid conflicts with other services.
7171
pingVMPort = 1234
72+
73+
// Metadata server constants.
74+
// v4MDS is the IPv4 address of the metadata server.
75+
v4MDS = "169.254.169.254 metadata.google.internal"
76+
// v6MDS is the IPv6 address of the metadata server.
77+
v6MDS = "fd20:ce::254 metadata.google.internal"
7278
)
7379

7480
var (

test_suites/nicsetup/setup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ func TestSetup(t *imagetest.TestWorkflow) error {
274274

275275
for _, vm := range allVMs {
276276
vm.AddMetadata(supportIpv6Key, strconv.FormatBool(supportsIpv6))
277-
vm.RunTests("TestNICSetup")
277+
vm.RunTests("TestNICSetup|TestMetadataHostsCompliance|TestMetadataResolutionAndReachability|TestMDSHostsCorrection")
278278
}
279279
return nil
280280
}

0 commit comments

Comments
 (0)