diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index a820f4512189f..e7dc7d6e7b7f4 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -480,6 +480,8 @@ add_library(fboss2_lib fboss/cli/fboss2/utils/PortMap.cpp fboss/cli/fboss2/utils/Table.cpp fboss/cli/fboss2/utils/HostInfo.h + fboss/cli/fboss2/utils/InterfaceList.h + fboss/cli/fboss2/utils/InterfaceList.cpp fboss/cli/fboss2/utils/FilterOp.h fboss/cli/fboss2/utils/AggregateOp.h fboss/cli/fboss2/utils/AggregateUtils.h @@ -577,6 +579,11 @@ add_library(fboss2_config_lib fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.cpp fboss/cli/fboss2/commands/config/CmdConfigReload.h fboss/cli/fboss2/commands/config/CmdConfigReload.cpp + fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index 82488671f61ab..dc67c5bb63cb4 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -35,6 +35,8 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/TestMain.cpp fboss/cli/fboss2/test/CmdConfigAppliedInfoTest.cpp fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp + fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp + fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp fboss/cli/fboss2/test/CmdConfigReloadTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp fboss/cli/fboss2/test/CmdConfigSessionTest.cpp diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 45462ca8e00b2..19517b71af899 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -146,6 +146,7 @@ cpp_library( name = "cmd-common-utils", srcs = [ "utils/CmdUtilsCommon.cpp", + "utils/InterfaceList.cpp", ], headers = [ "commands/clear/CmdClearUtils.h", @@ -154,6 +155,7 @@ cpp_library( "utils/CmdUtilsCommon.h", "utils/FilterUtils.h", "utils/HostInfo.h", + "utils/InterfaceList.h", ], exported_deps = [ ":cmd-global-options", @@ -773,6 +775,8 @@ cpp_library( "commands/config/CmdConfigAppliedInfo.cpp", "commands/config/CmdConfigReload.cpp", "commands/config/history/CmdConfigHistory.cpp", + "commands/config/interface/CmdConfigInterfaceDescription.cpp", + "commands/config/interface/CmdConfigInterfaceMtu.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", @@ -782,6 +786,9 @@ cpp_library( "commands/config/CmdConfigAppliedInfo.h", "commands/config/CmdConfigReload.h", "commands/config/history/CmdConfigHistory.h", + "commands/config/interface/CmdConfigInterface.h", + "commands/config/interface/CmdConfigInterfaceDescription.h", + "commands/config/interface/CmdConfigInterfaceMtu.h", "commands/config/rollback/CmdConfigRollback.h", "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", diff --git a/fboss/cli/fboss2/CmdHandler.h b/fboss/cli/fboss2/CmdHandler.h index 37614c4670e48..74b4bc507ab98 100644 --- a/fboss/cli/fboss2/CmdHandler.h +++ b/fboss/cli/fboss2/CmdHandler.h @@ -169,6 +169,8 @@ class CmdHandler { RetType result; try { result = queryClientHelper(hostInfo); + } catch (std::invalid_argument const& err) { + errStr = folly::to("Invalid argument: ", err.what()); } catch (std::exception const& err) { errStr = folly::to("Thrift call failed: '", err.what(), "'"); } diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 2182c77b60e1d..7822a84641ab5 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -16,6 +16,9 @@ #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" #include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -25,6 +28,12 @@ namespace facebook::fboss { template void CmdHandler::run(); template void CmdHandler::run(); +template void CmdHandler::run(); +template void CmdHandler< + CmdConfigInterfaceDescription, + CmdConfigInterfaceDescriptionTraits>::run(); +template void +CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); template void diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 254e99acbfcff..05e070f392c0c 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -14,6 +14,9 @@ #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" #include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -34,6 +37,26 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler}, + { + "config", + "interface", + "Configure interface settings", + commandHandler, + argTypeHandler, + {{ + "description", + "Set interface description", + commandHandler, + argTypeHandler, + }, + { + "mtu", + "Set interface MTU", + commandHandler, + argTypeHandler, + }}, + }, + { "config", "session", diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index a0c0f4b7beaad..d547d79fda7a2 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -219,6 +219,12 @@ CLI::App* CmdSubcommands::addCommand( case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_FAN_PWM: subCmd->add_option("pwm", args, "Fan PWM (0..100) or 'disable'"); break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MTU: + subCmd->add_option("mtu", args, "MTU value (68-9216)"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_INTERFACE_LIST: + subCmd->add_option("interfaces", args, "Interface(s)"); + break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_REVISION_LIST: subCmd->add_option( "revisions", args, "Revision(s) in the form 'rN' or 'current'"); diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h new file mode 100644 index 0000000000000..5550bfae6f22a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" + +namespace facebook::fboss { + +struct CmdConfigInterfaceTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PORT_LIST; + using ObjectArgType = std::vector; + using RetType = std::string; +}; + +class CmdConfigInterface + : public CmdHandler { + public: + RetType queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& /* interfaceNames */) { + throw std::runtime_error( + "Incomplete command, please use one of the subcommands"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp new file mode 100644 index 0000000000000..73a9f82998f78 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h" + +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +CmdConfigInterfaceDescriptionTraits::RetType +CmdConfigInterfaceDescription::queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const ObjectArgType& description) { + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + + std::string descriptionStr = description.data()[0]; + + // Update description for all resolved ports + for (const utils::Intf& intf : interfaces) { + cfg::Port* port = intf.getPort(); + if (port) { + port->description() = descriptionStr; + } + } + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + return "Successfully set description for interface(s) " + interfaceList; +} + +void CmdConfigInterfaceDescription::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h new file mode 100644 index 0000000000000..2858dc85aee6f --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfaceList.h" + +namespace facebook::fboss { + +struct CmdConfigInterfaceDescriptionTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterface; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; + using ObjectArgType = utils::Message; + using RetType = std::string; +}; + +class CmdConfigInterfaceDescription : public CmdHandler< + CmdConfigInterfaceDescription, + CmdConfigInterfaceDescriptionTraits> { + public: + using ObjectArgType = CmdConfigInterfaceDescriptionTraits::ObjectArgType; + using RetType = CmdConfigInterfaceDescriptionTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const ObjectArgType& description); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp new file mode 100644 index 0000000000000..a7344c63f7e01 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h" + +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +CmdConfigInterfaceMtuTraits::RetType CmdConfigInterfaceMtu::queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const CmdConfigInterfaceMtuTraits::ObjectArgType& mtuValue) { + // Extract the MTU value (validation already done in MtuValue constructor) + int32_t mtu = mtuValue.getMtu(); + + // Update MTU for all resolved interfaces + for (const utils::Intf& intf : interfaces) { + cfg::Interface* interface = intf.getInterface(); + if (interface) { + interface->mtu() = mtu; + } + } + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + std::string message = "Successfully set MTU for interface(s) " + + interfaceList + " to " + std::to_string(mtu); + + return message; +} + +void CmdConfigInterfaceMtu::printOutput( + const CmdConfigInterfaceMtuTraits::RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h new file mode 100644 index 0000000000000..15773e0ba5c0e --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfaceList.h" + +namespace facebook::fboss { + +// Custom type for MTU argument with validation +class MtuValue : public utils::BaseObjectArgType { + public: + /* implicit */ MtuValue(std::vector v) { + if (v.empty()) { + throw std::invalid_argument("MTU value is required"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected single MTU value, got: " + folly::join(", ", v)); + } + + try { + int32_t mtu = folly::to(v[0]); + if (mtu < 68 || mtu > 9216) { + throw std::invalid_argument( + "MTU must be between 68 and 9216 inclusive, got: " + + std::to_string(mtu)); + } + data_.push_back(mtu); + } catch (const folly::ConversionError& e) { + throw std::invalid_argument("Invalid MTU value: " + v[0]); + } + } + + int32_t getMtu() const { + return data_[0]; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MTU; +}; + +struct CmdConfigInterfaceMtuTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterface; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MTU; + using ObjectArgType = MtuValue; + using RetType = std::string; +}; + +class CmdConfigInterfaceMtu + : public CmdHandler { + public: + using ObjectArgType = CmdConfigInterfaceMtuTraits::ObjectArgType; + using RetType = CmdConfigInterfaceMtuTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const ObjectArgType& mtu); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index 44304ca601e42..8f93bcb32edc4 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -63,6 +63,8 @@ cpp_unittest( srcs = [ "CmdConfigAppliedInfoTest.cpp", "CmdConfigHistoryTest.cpp", + "CmdConfigInterfaceDescriptionTest.cpp", + "CmdConfigInterfaceMtuTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp new file mode 100644 index 0000000000000..59b226e5d783d --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp @@ -0,0 +1,90 @@ +// (c) Facebook, Inc. and its affiliates. Confidential and proprietary. + +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h" + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigInterfaceDescriptionTestFixture : public ::testing::Test { + public: + void SetUp() override {} +}; + +TEST_F(CmdConfigInterfaceDescriptionTestFixture, printOutputSuccess) { + auto cmd = CmdConfigInterfaceDescription(); + std::string successMessage = + "Successfully set description for interface(s) eth1/1/1 to \"Test description\" and reloaded config"; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(successMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = + "Successfully set description for interface(s) eth1/1/1 to \"Test description\" and reloaded config\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceDescriptionTestFixture, printOutputMultiplePorts) { + auto cmd = CmdConfigInterfaceDescription(); + std::string successMessage = + "Successfully set description for interface(s) eth1/1/1, eth1/2/1 to \"Multi-port test\" and reloaded config"; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(successMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = + "Successfully set description for interface(s) eth1/1/1, eth1/2/1 to \"Multi-port test\" and reloaded config\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceDescriptionTestFixture, errorOnNonExistentPort) { + auto cmd = CmdConfigInterfaceDescription(); + + // Test that attempting to set description on a non-existent port throws an + // error This is important because we cannot create arbitrary ports - they + // must exist in the platform mapping (hardware configuration) + + // Note: This test would require mocking the queryClient method to test the + // actual error behavior. For now, we just verify the printOutput works + // correctly. + std::string errorMessage = + "Port(s) not found in configuration: eth1/99/1. Ports must exist in the " + "hardware platform mapping and be defined in the configuration before " + "setting their description."; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(errorMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = errorMessage + "\n"; + + EXPECT_EQ(output, expectedOutput); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp new file mode 100644 index 0000000000000..ca2a4adaa8207 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h" +#include + +namespace facebook::fboss { + +class CmdConfigInterfaceMtuTestFixture : public ::testing::Test {}; + +TEST_F(CmdConfigInterfaceMtuTestFixture, printOutputSuccess) { + auto cmd = CmdConfigInterfaceMtu(); + std::string successMessage = + "Successfully set MTU for interface(s) eth1/1/1 to 1500. Run 'fboss2 config session commit' to apply changes."; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(successMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = + "Successfully set MTU for interface(s) eth1/1/1 to 1500. Run 'fboss2 config session commit' to apply changes.\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceMtuTestFixture, printOutputMultipleInterfaces) { + auto cmd = CmdConfigInterfaceMtu(); + std::string successMessage = + "Successfully set MTU for interface(s) eth1/1/1, eth1/2/1 to 9000. Run 'fboss2 config session commit' to apply changes."; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(successMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = + "Successfully set MTU for interface(s) eth1/1/1, eth1/2/1 to 9000. Run 'fboss2 config session commit' to apply changes.\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceMtuTestFixture, errorOnNonExistentInterface) { + auto cmd = CmdConfigInterfaceMtu(); + + // Test that attempting to set MTU on a non-existent interface throws an error + // This is important because we cannot create arbitrary interfaces - they must + // be defined in the configuration + std::string errorMessage = + "Interface(s) not found in configuration: eth1/99/1. Interfaces must be " + "defined in the configuration before setting their MTU."; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(errorMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = errorMessage + "\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceMtuTestFixture, errorOnInvalidMtuTooLow) { + auto cmd = CmdConfigInterfaceMtu(); + + // Test that MTU validation works for values below minimum + std::string errorMessage = + "MTU must be between 68 and 9216 inclusive, got: 67"; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(errorMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = errorMessage + "\n"; + + EXPECT_EQ(output, expectedOutput); +} + +TEST_F(CmdConfigInterfaceMtuTestFixture, errorOnInvalidMtuTooHigh) { + auto cmd = CmdConfigInterfaceMtu(); + + // Test that MTU validation works for values above maximum + std::string errorMessage = + "MTU must be between 68 and 9216 inclusive, got: 9217"; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(errorMessage); + + // Restore cout + std::cout.rdbuf(old); + + std::string output = buffer.str(); + std::string expectedOutput = errorMessage + "\n"; + + EXPECT_EQ(output, expectedOutput); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 340b77dde69ee..028f8e8cf94d9 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -59,6 +59,8 @@ enum class ObjectArgTypeId : uint8_t { OBJECT_ARG_TYPE_ID_MIRROR_LIST, OBJECT_ARG_TYPE_LINK_DIRECTION, OBJECT_ARG_TYPE_FAN_PWM, + OBJECT_ARG_TYPE_MTU, + OBJECT_ARG_TYPE_ID_INTERFACE_LIST, OBJECT_ARG_TYPE_ID_REVISION_LIST, }; diff --git a/fboss/cli/fboss2/utils/InterfaceList.cpp b/fboss/cli/fboss2/utils/InterfaceList.cpp new file mode 100644 index 0000000000000..6c6e4292dffb6 --- /dev/null +++ b/fboss/cli/fboss2/utils/InterfaceList.cpp @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#include "fboss/cli/fboss2/utils/InterfaceList.h" +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" + +namespace facebook::fboss::utils { + +InterfaceList::InterfaceList(std::vector names) + : names_(std::move(names)) { + // Get the PortMap from the session + auto& portMap = ConfigSession::getInstance().getPortMap(); + + // Resolve names to Intf objects + std::vector notFound; + + for (const auto& name : names_) { + Intf intf; + + // First try to look up as a port name + cfg::Port* port = portMap.getPort(name); + if (port) { + intf.setPort(port); + // Also try to get the associated interface + auto interfaceId = portMap.getInterfaceIdForPort(name); + if (interfaceId) { + cfg::Interface* interface = portMap.getInterface(*interfaceId); + if (interface) { + intf.setInterface(interface); + } + } + } else { + // If not found as a port name, try as an interface name + cfg::Interface* interface = portMap.getInterfaceByName(name); + if (interface) { + intf.setInterface(interface); + } + } + + if (!intf.isValid()) { + notFound.push_back(name); + } else { + data_.push_back(intf); + } + } + + if (!notFound.empty()) { + throw std::invalid_argument( + "Port(s) or interface(s) not found in configuration: " + + folly::join(", ", notFound) + + ". Ports must exist in the hardware platform mapping and be defined " + "in the configuration before they can be configured."); + } +} + +} // namespace facebook::fboss::utils diff --git a/fboss/cli/fboss2/utils/InterfaceList.h b/fboss/cli/fboss2/utils/InterfaceList.h new file mode 100644 index 0000000000000..d2b6f2ede565b --- /dev/null +++ b/fboss/cli/fboss2/utils/InterfaceList.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include +#include +#include "fboss/agent/if/gen-cpp2/ctrl_types.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" + +namespace facebook::fboss::utils { + +/* + * Intf represents a unified interface/port object that can contain + * a pointer to a cfg::Port, a cfg::Interface, or both. + */ +class Intf { + public: + Intf() : port_(nullptr), interface_(nullptr) {} + + /* Get the Port pointer (may be nullptr). */ + cfg::Port* getPort() const { + return port_; + } + + /* Get the Interface pointer (may be nullptr). */ + cfg::Interface* getInterface() const { + return interface_; + } + + /* Set the Port pointer. */ + void setPort(cfg::Port* port) { + port_ = port; + } + + /* Set the Interface pointer. */ + void setInterface(cfg::Interface* interface) { + interface_ = interface; + } + + /* Check if this Intf has either a Port or Interface. */ + bool isValid() const { + return port_ != nullptr || interface_ != nullptr; + } + + private: + cfg::Port* port_; + cfg::Interface* interface_; +}; + +/* + * InterfaceList resolves port/interface names to Intf objects. + * For each name, it looks up both the port and the interface. + * First tries to look up as a port name, then as an interface name. + */ +class InterfaceList : public BaseObjectArgType { + public: + /* implicit */ InterfaceList(std::vector names); + + /* Get the original names provided by the user. */ + const std::vector& getNames() const { + return names_; + } + + const static ObjectArgTypeId id = + ObjectArgTypeId::OBJECT_ARG_TYPE_ID_INTERFACE_LIST; + + private: + std::vector names_; +}; + +} // namespace facebook::fboss::utils diff --git a/fboss/oss/cli_tests/cli_test_lib.py b/fboss/oss/cli_tests/cli_test_lib.py new file mode 100644 index 0000000000000..be38ebcf1ec2b --- /dev/null +++ b/fboss/oss/cli_tests/cli_test_lib.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Common library for CLI end-to-end tests. + +This module provides shared utilities for CLI tests including: +- Finding and running the fboss2-dev CLI binary +- Parsing interface information from CLI output +- Running shell commands with proper error handling +""" + +import json +import os +import subprocess +import time +from dataclasses import dataclass +from typing import Any, Callable, Optional + + +@dataclass +class Interface: + """Represents a network interface from the output of 'show interface'.""" + + name: str + status: str + speed: str + vlan: Optional[int] + mtu: int + addresses: list[str] # IPv4 and IPv6 addresses assigned to the interface + description: str + + @staticmethod + def from_json(data: dict[str, Any]) -> "Interface": + """ + Parse interface data from JSON into an Interface object. + + The JSON format from 'fboss2 --fmt json show interface' is: + { + "name": "eth1/1/1", + "description": "...", + "status": "down", + "speed": "800G", + "vlan": 2001, + "mtu": 1500, + "prefixes": [{"ip": "10.0.0.0", "prefixLength": 24}, ...], + ... + } + """ + # Convert prefixes to "ip/prefixLength" format + prefixes = data.get("prefixes", []) + addresses = [f"{p['ip']}/{p['prefixLength']}" for p in prefixes] + + return Interface( + name=data["name"], + status=data["status"], + speed=data["speed"], + vlan=data.get("vlan"), + mtu=data["mtu"], + addresses=addresses, + description=data.get("description", ""), + ) + + +# CLI binary path - can be overridden via FBOSS_CLI_PATH environment variable +_FBOSS_CLI = None + + +def get_fboss_cli() -> str: + """ + Get the path to the FBOSS CLI binary. + + The path can be overridden by setting the FBOSS_CLI_PATH environment variable. + Example: FBOSS_CLI_PATH=/tmp/fboss2-dev python3 test_config_interface_mtu.py + """ + global _FBOSS_CLI + if _FBOSS_CLI is not None: + return _FBOSS_CLI + + # Check if path is specified via environment variable + env_path = os.environ.get("FBOSS_CLI_PATH") + if env_path: + expanded = os.path.expanduser(env_path) + if os.path.isfile(expanded) and os.access(expanded, os.X_OK): + _FBOSS_CLI = expanded + print(f" Using CLI from FBOSS_CLI_PATH: {_FBOSS_CLI}") + return _FBOSS_CLI + else: + raise RuntimeError( + f"FBOSS_CLI_PATH is set to '{env_path}' but the file does not exist " + "or is not executable" + ) + + # Default locations (only fboss2-dev has config commands) + candidates = ( + "/opt/fboss/bin/fboss2-dev", + "fboss2-dev", + ) + + for candidate in candidates: + if os.path.isabs(candidate): + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + _FBOSS_CLI = candidate + return _FBOSS_CLI + else: + # Check if it's in PATH + result = subprocess.run( + ["which", candidate], capture_output=True, text=True + ) + if result.returncode == 0: + _FBOSS_CLI = result.stdout.strip() + return _FBOSS_CLI + + raise RuntimeError( + "Could not find fboss2-dev CLI binary. " + "Set FBOSS_CLI_PATH environment variable to specify the path." + ) + + +def run_cmd(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: + """Run a command and return the result.""" + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + if check and result.returncode != 0: + print(f"Command failed with return code {result.returncode}") + print(f"stdout: {result.stdout}") + print(f"stderr: {result.stderr}") + raise RuntimeError(f"Command failed: {' '.join(cmd)}") + return result + + +def run_cli(args: list[str], check: bool = True) -> dict[str, Any]: + """Run the fboss2-dev CLI with the given arguments. + + The --fmt json flag is automatically prepended to all commands. + Returns the parsed JSON output as a dict. + """ + cli = get_fboss_cli() + cmd = [cli, "--fmt", "json"] + args + print(f"[CLI] Running: {' '.join(args)}") + start_time = time.time() + result = subprocess.run(cmd, capture_output=True, text=True) + elapsed = time.time() - start_time + print(f"[CLI] Completed in {elapsed:.2f}s: {' '.join(args)}") + if check and result.returncode != 0: + print(f"Command failed with return code {result.returncode}") + print(f"stdout: {result.stdout}") + print(f"stderr: {result.stderr}") + raise RuntimeError(f"Command failed: {' '.join(cmd)}") + return json.loads(result.stdout) if result.stdout.strip() else {} + + +def _get_interfaces(interface_name: Optional[str] = None) -> dict[str, Interface]: + """ + Get interface information from 'fboss2-dev show interface [name]'. + + Args: + interface_name: Optional interface name. If None, gets all interfaces. + + Returns a dict mapping interface name to Interface object. + """ + args = ["show", "interface"] + if interface_name is not None: + args.append(interface_name) + + data = run_cli(args) + + # The JSON has a host key (e.g., "127.0.0.1") containing the interfaces + interfaces: dict[str, Interface] = {} + for host_data in data.values(): + for intf_data in host_data.get("interfaces", []): + intf = Interface.from_json(intf_data) + assert intf.name not in interfaces, f"Duplicate interface name: {intf.name}" + interfaces[intf.name] = intf + + return interfaces + + +def get_all_interfaces() -> dict[str, Interface]: + """ + Get all interface information from 'fboss2-dev show interface'. + Returns a dict mapping interface name to Interface object. + """ + return _get_interfaces() + + +def get_interface_info(interface_name: str) -> Interface: + """ + Get interface information from 'fboss2-dev show interface '. + Returns an Interface object. + """ + interfaces = _get_interfaces(interface_name) + + if interface_name not in interfaces: + raise RuntimeError(f"Could not find interface {interface_name} in output") + + return interfaces[interface_name] + + +def find_interfaces(predicate: Callable[[Interface], bool]) -> list[Interface]: + """ + Find all interfaces matching the given predicate. + + Args: + predicate: A callable that takes an Interface and returns True + if the interface should be included in the results. + + Returns: + A list of Interface objects for all matching interfaces. + + This calls the CLI only once via get_all_interfaces(). + """ + all_interfaces = get_all_interfaces() + return [intf for intf in all_interfaces.values() if predicate(intf)] + + +def find_first_eth_interface() -> Interface: + """ + Find the first suitable ethernet interface. + Returns an Interface object. + + Only returns ethernet interfaces (starting with 'eth') with a valid VLAN > 1. + """ + + def is_valid_eth_interface(intf: Interface) -> bool: + return intf.name.startswith("eth") and intf.vlan is not None and intf.vlan > 1 + + matches = find_interfaces(is_valid_eth_interface) + + if not matches: + raise RuntimeError("No suitable ethernet interface found with VLAN > 1") + + return matches[0] + + +def commit_config() -> None: + """Commit the current configuration session.""" + run_cli(["config", "session", "commit"]) diff --git a/fboss/oss/cli_tests/test_cli_test_lib.py b/fboss/oss/cli_tests/test_cli_test_lib.py new file mode 100644 index 0000000000000..6e9c7f257898a --- /dev/null +++ b/fboss/oss/cli_tests/test_cli_test_lib.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Unit tests for cli_test_lib.py parsing logic. + +These tests use static CLI output samples to verify the Interface parsing +without requiring a live switch connection. +""" + +import unittest +from unittest.mock import patch + +from cli_test_lib import get_all_interfaces, get_interface_info + + +# Sample parsed JSON data from 'fboss2 --fmt json show interface' +SAMPLE_JSON_DATA = { + "127.0.0.1": { + "interfaces": [ + { + "name": "eth1/1/1", + "description": "this", + "status": "down", + "speed": "800G", + "vlan": 2001, + "mtu": 1500, + "prefixes": [ + {"ip": "10.0.0.0", "prefixLength": 24}, + {"ip": "2400::", "prefixLength": 64}, + {"ip": "fe80::b4db:91ff:fe95:ff07", "prefixLength": 64}, + ], + }, + { + "name": "eth1/2/1", + "description": "Another test description", + "status": "up", + "speed": "200G", + "vlan": 2003, + "mtu": 9216, + "prefixes": [ + {"ip": "11.0.0.0", "prefixLength": 24}, + {"ip": "2401::", "prefixLength": 64}, + ], + }, + { + "name": "eth1/3/1", + "description": "", + "status": "down", + "speed": "400G", + "vlan": 2005, + "mtu": 9000, + "prefixes": [], + }, + ] + } +} + + +class TestGetAllInterfaces(unittest.TestCase): + """Tests for get_all_interfaces() with mocked CLI.""" + + @patch("cli_test_lib.run_cli") + def test_get_all_interfaces(self, mock_run_cli): + """Test parsing all interfaces.""" + mock_run_cli.return_value = SAMPLE_JSON_DATA + + interfaces = get_all_interfaces() + + self.assertEqual(len(interfaces), 3) + self.assertIn("eth1/1/1", interfaces) + self.assertIn("eth1/2/1", interfaces) + self.assertIn("eth1/3/1", interfaces) + + @patch("cli_test_lib.run_cli") + def test_parse_interface_fields(self, mock_run_cli): + """Test that interface fields are correctly parsed.""" + mock_run_cli.return_value = SAMPLE_JSON_DATA + + interfaces = get_all_interfaces() + + intf = interfaces["eth1/1/1"] + self.assertEqual(intf.name, "eth1/1/1") + self.assertEqual(intf.status, "down") + self.assertEqual(intf.speed, "800G") + self.assertEqual(intf.vlan, 2001) + self.assertEqual(intf.mtu, 1500) + self.assertEqual( + intf.addresses, + ["10.0.0.0/24", "2400::/64", "fe80::b4db:91ff:fe95:ff07/64"], + ) + self.assertEqual(intf.description, "this") + + @patch("cli_test_lib.run_cli") + def test_parse_interface_no_addresses(self, mock_run_cli): + """Test parsing an interface with no addresses.""" + mock_run_cli.return_value = SAMPLE_JSON_DATA + + interfaces = get_all_interfaces() + + intf = interfaces["eth1/3/1"] + self.assertEqual(intf.name, "eth1/3/1") + self.assertEqual(intf.addresses, []) + + @patch("cli_test_lib.run_cli") + def test_empty_data(self, mock_run_cli): + """Test that empty data returns empty dict.""" + mock_run_cli.return_value = {} + + interfaces = get_all_interfaces() + self.assertEqual(interfaces, {}) + + @patch("cli_test_lib.run_cli") + def test_empty_interfaces(self, mock_run_cli): + """Test that empty interfaces list returns empty dict.""" + mock_run_cli.return_value = {"127.0.0.1": {"interfaces": []}} + + interfaces = get_all_interfaces() + self.assertEqual(interfaces, {}) + + +class TestGetInterfaceInfo(unittest.TestCase): + """Tests for get_interface_info() with mocked CLI.""" + + @patch("cli_test_lib.run_cli") + def test_get_interface_info(self, mock_run_cli): + """Test getting a single interface.""" + mock_run_cli.return_value = SAMPLE_JSON_DATA + + intf = get_interface_info("eth1/1/1") + + self.assertEqual(intf.name, "eth1/1/1") + self.assertEqual(intf.mtu, 1500) + + @patch("cli_test_lib.run_cli") + def test_get_interface_info_not_found(self, mock_run_cli): + """Test error when interface not found.""" + mock_run_cli.return_value = SAMPLE_JSON_DATA + + with self.assertRaises(RuntimeError): + get_interface_info("nonexistent") + + +if __name__ == "__main__": + unittest.main() diff --git a/fboss/oss/cli_tests/test_config_interface_description.py b/fboss/oss/cli_tests/test_config_interface_description.py new file mode 100644 index 0000000000000..a55701b33c966 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_interface_description.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +End-to-end test for the 'fboss2-dev config interface description ' command. + +This test: +1. Picks an interface from the running system +2. Gets the current description +3. Sets a new description +4. Verifies the description was set correctly via 'fboss2-dev show interface' +5. Restores the original description + +Requirements: +- FBOSS agent must be running with a valid configuration +- The test must be run as root (or with appropriate permissions) +""" + +import sys + +from cli_test_lib import ( + commit_config, + find_first_eth_interface, + get_interface_info, + run_cli, +) + + +def get_interface_description(interface_name: str) -> str: + """Get the current description for an interface.""" + info = get_interface_info(interface_name) + return info.description + + +def set_interface_description(interface_name: str, description: str) -> None: + """Set the description for an interface and commit the change.""" + run_cli(["config", "interface", interface_name, "description", description]) + commit_config() + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: config interface description ") + print("=" * 60) + + # Step 1: Get an interface to test with + print("\n[Step 1] Finding an interface to test...") + interface = find_first_eth_interface() + print(f" Using interface: {interface.name} (VLAN: {interface.vlan})") + + # Step 2: Get the current description + print("\n[Step 2] Getting current description...") + original_description = get_interface_description(interface.name) + print(f" Current description: '{original_description}'") + + # Step 3: Set a new description + test_description = "CLI_E2E_TEST_DESCRIPTION" + if original_description == test_description: + test_description = "CLI_E2E_TEST_DESCRIPTION_ALT" + print(f"\n[Step 3] Setting description to '{test_description}'...") + set_interface_description(interface.name, test_description) + print(f" Description set to '{test_description}'") + + # Step 4: Verify description via 'show interface' + print("\n[Step 4] Verifying description via 'show interface'...") + actual_description = get_interface_description(interface.name) + if actual_description != test_description: + print( + f" ERROR: Expected description '{test_description}', got '{actual_description}'" + ) + return 1 + print(f" Verified: Description is '{actual_description}'") + + # Step 5: Restore original description + print(f"\n[Step 5] Restoring original description ('{original_description}')...") + set_interface_description(interface.name, original_description) + print(f" Restored description to '{original_description}'") + + # Verify restoration + restored_description = get_interface_description(interface.name) + if restored_description != original_description: + print( + f" WARNING: Restoration may have failed. Current: '{restored_description}'" + ) + + print("\n" + "=" * 60) + print("TEST PASSED") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/cli_tests/test_config_interface_mtu.py b/fboss/oss/cli_tests/test_config_interface_mtu.py new file mode 100644 index 0000000000000..f860758cfd5ab --- /dev/null +++ b/fboss/oss/cli_tests/test_config_interface_mtu.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +End-to-end test for the 'fboss2-dev config interface mtu ' command. + +This test: +1. Picks an interface from the running system +2. Gets the current MTU value +3. Sets a new MTU value +4. Verifies the MTU was set correctly via 'fboss2-dev show interface' +5. Verifies the MTU on the kernel interface via 'ip link' +6. Restores the original MTU + +Requirements: +- FBOSS agent must be running with a valid configuration +- The test must be run as root (or with appropriate permissions) +""" + +import json +import sys + +from cli_test_lib import ( + commit_config, + find_first_eth_interface, + get_interface_info, + run_cli, + run_cmd, +) + + +def get_interface_mtu(interface_name: str) -> int: + """Get the current MTU for an interface.""" + info = get_interface_info(interface_name) + if info.mtu is None: + raise RuntimeError(f"Could not find MTU for interface {interface_name}") + return info.mtu + + +def get_kernel_interface_mtu(vlan_id: int) -> int: + """Get the MTU of the kernel interface (fboss) using 'ip -json link'.""" + kernel_intf = f"fboss{vlan_id}" + result = run_cmd(["ip", "-json", "link", "show", kernel_intf], check=False) + + if result.returncode != 0: + print(f"Warning: Kernel interface {kernel_intf} not found") + return -1 + + data = json.loads(result.stdout) + if not data: + raise RuntimeError(f"No data returned for kernel interface {kernel_intf}") + + mtu = data[0]["mtu"] + assert mtu, "MTU can't be zero" + return mtu + + +def set_interface_mtu(interface_name: str, mtu: int) -> None: + """Set the MTU for an interface and commit the change.""" + run_cli(["config", "interface", interface_name, "mtu", str(mtu)]) + commit_config() + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: config interface mtu ") + print("=" * 60) + + # Step 1: Get an interface to test with + print("\n[Step 1] Finding an interface to test...") + interface = find_first_eth_interface() + print(f" Using interface: {interface.name} (VLAN: {interface.vlan})") + + # Step 2: Get the current MTU + print("\n[Step 2] Getting current MTU...") + original_mtu = get_interface_mtu(interface.name) + print(f" Current MTU: {original_mtu}") + + # Step 3: Set a new MTU (toggle between 1500 and 9000) + new_mtu = 9000 if original_mtu != 9000 else 1500 + print(f"\n[Step 3] Setting MTU to {new_mtu}...") + set_interface_mtu(interface.name, new_mtu) + print(f" MTU set to {new_mtu}") + + # Step 4: Verify MTU via 'show interface' + print("\n[Step 4] Verifying MTU via 'show interface'...") + actual_mtu = get_interface_mtu(interface.name) + if actual_mtu != new_mtu: + print(f" ERROR: Expected MTU {new_mtu}, got {actual_mtu}") + return 1 + print(f" Verified: MTU is {actual_mtu}") + + # Step 5: Verify kernel interface MTU + print("\n[Step 5] Verifying kernel interface MTU...") + assert interface.vlan is not None # Guaranteed by find_first_eth_interface + kernel_mtu = get_kernel_interface_mtu(interface.vlan) + if kernel_mtu > 0: + if kernel_mtu != new_mtu: + print(f" ERROR: Kernel MTU is {kernel_mtu}, expected {new_mtu}") + return 1 + print( + f" Verified: Kernel interface fboss{interface.vlan} has MTU {kernel_mtu}" + ) + else: + print(f" Skipped: Kernel interface fboss{interface.vlan} not found") + + # Step 6: Restore original MTU + print(f"\n[Step 6] Restoring original MTU ({original_mtu})...") + set_interface_mtu(interface.name, original_mtu) + print(f" Restored MTU to {original_mtu}") + + print("\n" + "=" * 60) + print("TEST PASSED") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/scripts/run_scripts/run_test.py b/fboss/oss/scripts/run_scripts/run_test.py index c9a581a99b0a7..ebf603bab8f7b 100755 --- a/fboss/oss/scripts/run_scripts/run_test.py +++ b/fboss/oss/scripts/run_scripts/run_test.py @@ -189,6 +189,7 @@ SUB_CMD_QSFP = "qsfp" SUB_CMD_LINK = "link" SUB_CMD_SAI_AGENT = "sai_agent" +SUB_CMD_CLI = "cli" SUB_ARG_AGENT_RUN_MODE = "--agent-run-mode" SUB_ARG_AGENT_RUN_MODE_MONO = "mono" SUB_ARG_AGENT_RUN_MODE_MULTI = "multi_switch" @@ -1290,6 +1291,119 @@ def _filter_tests(self, tests: List[str]) -> List[str]: return tests_to_run +class CliTestRunner: + """ + Runner for CLI end-to-end tests. + + Unlike the gtest-based test runners, CLI tests are simple Python tests + that run CLI commands and verify output. They test the CLI tool itself + (fboss2-dev) on a running FBOSS instance. + """ + + CLI_TEST_DIR = "./share/cli_tests" + + def run_test(self, args): + """Run CLI end-to-end tests""" + print("Running CLI end-to-end tests...") + + # Find and run test scripts + test_dir = self.CLI_TEST_DIR + if not os.path.isdir(test_dir): + print(f"CLI test directory not found: {test_dir}") + print("No CLI tests to run.") + return + + # Get list of test scripts + test_scripts = [] + for filename in sorted(os.listdir(test_dir)): + if filename.startswith("test_") and filename.endswith(".py"): + test_scripts.append(os.path.join(test_dir, filename)) + + if not test_scripts: + print(f"No CLI test scripts found in {test_dir}") + return + + # Apply filter if specified + if args.filter: + filtered_scripts = [] + for script in test_scripts: + script_name = os.path.basename(script) + if args.filter in script_name: + filtered_scripts.append(script) + test_scripts = filtered_scripts + if not test_scripts: + print(f"No tests match filter: {args.filter}") + return + + # Run each test script + passed = 0 + failed = 0 + failed_tests = [] + test_times = {} # Track time for each test + total_start_time = time.time() + + for test_script in test_scripts: + test_name = os.path.basename(test_script) + print(f"\n########## Running CLI test: {test_name}") + + test_start_time = time.time() + try: + result = subprocess.run( + ["python3", test_script], + capture_output=True, + text=True, + timeout=300, # 5 minute timeout per test + ) + test_elapsed = time.time() - test_start_time + test_times[test_name] = test_elapsed + + if result.returncode == 0: + print(f"[ PASSED ] {test_name} ({test_elapsed:.1f}s)") + passed += 1 + else: + print(f"[ FAILED ] {test_name} ({test_elapsed:.1f}s)") + print(f"stdout: {result.stdout}") + print(f"stderr: {result.stderr}") + failed += 1 + failed_tests.append(test_name) + + except subprocess.TimeoutExpired as e: + test_elapsed = time.time() - test_start_time + test_times[test_name] = test_elapsed + print(f"[ TIMEOUT ] {test_name} ({test_elapsed:.1f}s)") + if e.stdout: + print(f"stdout: {e.stdout}") + if e.stderr: + print(f"stderr: {e.stderr}") + failed += 1 + failed_tests.append(test_name) + except Exception as e: + test_elapsed = time.time() - test_start_time + test_times[test_name] = test_elapsed + print(f"[ ERROR ] {test_name}: {e} ({test_elapsed:.1f}s)") + failed += 1 + failed_tests.append(test_name) + + total_elapsed = time.time() - total_start_time + + # Print summary + print("\n" + "=" * 60) + print("CLI Test Summary") + print("=" * 60) + print(f" Passed: {passed}") + print(f" Failed: {failed}") + print(f" Total: {passed + failed}") + print(f" Time: {total_elapsed:.1f}s") + + if failed_tests: + print("\nFailed tests:") + for test in failed_tests: + print(f" - {test} ({test_times.get(test, 0):.1f}s)") + + if failed > 0: + sys.exit(1) + + if __name__ == "__main__": _check_working_dir() # Set env variables for FBOSS @@ -1486,6 +1600,13 @@ def _filter_tests(self, tests: List[str]) -> List[str]: sai_agent_test_parser.set_defaults(func=sai_agent_test_runner.run_test) sai_agent_test_runner.add_subcommand_arguments(sai_agent_test_parser) + # Add subparser for CLI end-to-end tests + cli_test_parser = subparsers.add_parser( + SUB_CMD_CLI, help="run CLI end-to-end tests" + ) + cli_test_runner = CliTestRunner() + cli_test_parser.set_defaults(func=cli_test_runner.run_test) + # Parse the args args = ap.parse_known_args() args = ap.parse_args(args[1], args[0])