diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index a2a71815422a8..6ac92219630c5 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -505,6 +505,8 @@ add_library(fboss2_lib fboss/cli/fboss2/commands/show/interface/prbs/stats/CmdShowInterfacePrbsStats.h fboss/cli/fboss2/commands/show/rif/CmdShowRif.h fboss/cli/fboss2/commands/show/rif/CmdShowRif.cpp + fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.cpp + fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h fboss/cli/fboss2/commands/show/sdk/dump/CmdShowSdkDump.h fboss/cli/fboss2/commands/show/sdk/dump/CmdShowSdkDump.cpp fboss/cli/fboss2/commands/show/systemport/CmdShowSystemPort.h @@ -517,6 +519,7 @@ add_library(fboss2_lib fboss/cli/fboss2/commands/show/transceiver/CmdShowTransceiver.cpp fboss/cli/fboss2/commands/start/pcap/CmdStartPcap.h fboss/cli/fboss2/commands/stop/pcap/CmdStopPcap.h + fboss/cli/fboss2/CliAppInit.h fboss/cli/fboss2/CmdSubcommands.cpp fboss/cli/fboss2/oss/CmdGlobalOptions.cpp fboss/cli/fboss2/oss/CmdList.cpp @@ -628,28 +631,72 @@ 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.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/interface/CmdConfigInterfaceQueuingPolicy.cpp + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h + fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp + fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h + fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h + fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.cpp + fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h + fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.cpp + fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h + fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp + fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h + fboss/cli/fboss2/commands/config/l2/CmdConfigL2.cpp + fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h + fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h + fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp + fboss/cli/fboss2/commands/config/qos/CmdConfigQos.cpp fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h + fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.cpp + fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h + fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp + fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h + fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.cpp + fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h + fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp + fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h + fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.cpp + fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h + fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp + fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp + fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h + fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.cpp fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp + fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h + fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp + fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.cpp + fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h + fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.cpp + fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h + fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h + fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp + fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.cpp + fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h + fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h + fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp + fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h + fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp fboss/cli/fboss2/session/ConfigSession.h fboss/cli/fboss2/session/ConfigSession.cpp - fboss/cli/fboss2/utils/InterfaceList.h + fboss/cli/fboss2/session/Git.h + fboss/cli/fboss2/session/Git.cpp + fboss/cli/fboss2/utils/InterfacesConfig.cpp + fboss/cli/fboss2/utils/InterfacesConfig.h fboss/cli/fboss2/utils/InterfaceList.cpp + fboss/cli/fboss2/utils/InterfaceList.h fboss/cli/fboss2/CmdListConfig.cpp - fboss/cli/fboss2/CmdHandlerImplConfig.cpp ) target_link_libraries(fboss2_config_lib diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index f3f68a8f20401..d2d25bdeb6c1d 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -33,15 +33,22 @@ gtest_discover_tests(fboss2_framework_test) # cmd_test - Command tests from BUCK file add_executable(fboss2_cmd_test + fboss/cli/fboss2/oss/CmdListConfig.cpp 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/CmdConfigInterfaceSwitchportAccessVlanTest.cpp + fboss/cli/fboss2/test/CmdConfigL2LearningModeTest.cpp + fboss/cli/fboss2/test/CmdConfigInterfaceTest.cpp fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp fboss/cli/fboss2/test/CmdConfigReloadTest.cpp + fboss/cli/fboss2/test/CmdConfigSessionClearTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp fboss/cli/fboss2/test/CmdConfigSessionTest.cpp + fboss/cli/fboss2/test/CmdConfigVlanPortTaggingModeTest.cpp + fboss/cli/fboss2/test/CmdConfigVlanStaticMacTest.cpp + fboss/cli/fboss2/test/CmdGetPcapTest.cpp + fboss/cli/fboss2/test/CmdListConfigTest.cpp fboss/cli/fboss2/test/CmdSetPortStateTest.cpp fboss/cli/fboss2/test/CmdShowAclTest.cpp fboss/cli/fboss2/test/CmdShowAgentSslTest.cpp @@ -53,7 +60,6 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/CmdShowL2Test.cpp fboss/cli/fboss2/test/CmdShowLldpTest.cpp fboss/cli/fboss2/test/CmdShowNdpTest.cpp - fboss/cli/fboss2/test/CmdGetPcapTest.cpp fboss/cli/fboss2/test/CmdShowAggregatePortTest.cpp fboss/cli/fboss2/test/CmdShowCpuPortTest.cpp fboss/cli/fboss2/test/CmdShowExampleTest.cpp @@ -70,10 +76,12 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/CmdShowProductTest.cpp fboss/cli/fboss2/test/CmdShowRouteDetailsTest.cpp fboss/cli/fboss2/test/CmdShowRouteSummaryTest.cpp + fboss/cli/fboss2/test/CmdShowRunningConfigTest.cpp fboss/cli/fboss2/test/CmdShowTeFlowTest.cpp # fboss/cli/fboss2/test/CmdShowTransceiverTest.cpp - excluded (depends on configerator bgp namespace) fboss/cli/fboss2/test/CmdStartPcapTest.cpp fboss/cli/fboss2/test/CmdStopPcapTest.cpp + fboss/cli/fboss2/test/GitTest.cpp fboss/cli/fboss2/test/PortMapTest.cpp ) @@ -106,3 +114,35 @@ target_link_libraries(thrift_latency_test Folly::folly FBThrift::thriftcpp2 ) + +# cli_test - CLI E2E test binary +# +# CLI tests are platform/SAI independent - they test the CLI binary which +# communicates with the agent via Thrift, without running the actual fboss2-dev +# binary. +add_executable(cli_test + fboss/cli/fboss2/oss/CmdListConfig.cpp + fboss/cli/test/CliTest.cpp + fboss/cli/test/ConfigInterfaceDescriptionTest.cpp + fboss/cli/test/ConfigInterfaceMtuTest.cpp + fboss/cli/test/ConfigQosPolicyMapTest.cpp + fboss/cli/test/ConfigSessionClearTest.cpp +) + +target_link_libraries(cli_test + fboss2_lib + fboss2_config_lib + thrift_service_utils + CLI11::CLI11 + ${GTEST} + ${LIBGMOCK_LIBRARIES} + Folly::folly + Folly::folly_test_util + FBThrift::thriftcpp2 + gflags + fmt::fmt +) + +target_include_directories(cli_test PUBLIC + ${CMAKE_SOURCE_DIR} +) diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 10b6c66510e12..ccc527d3f3990 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -79,6 +79,7 @@ cpp_library( "CmdSubcommands.cpp", ], headers = [ + "CliAppInit.h", "CmdArgsLists.h", "CmdSubcommands.h", ], @@ -559,6 +560,7 @@ cpp_library( "commands/show/product/CmdShowProduct.h", "commands/show/product/CmdShowProductDetails.h", "commands/show/rif/CmdShowRif.h", + "commands/show/running_config/CmdShowRunningConfig.h", "commands/show/route/CmdShowRoute.h", "commands/show/route/CmdShowRouteDetails.h", "commands/show/route/CmdShowRouteSummary.h", @@ -851,18 +853,40 @@ auto_tab_complete( cpp_library( name = "fboss2-config-lib", srcs = [ - "CmdHandlerImplConfig.cpp", "CmdListConfig.cpp", "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/interface/CmdConfigInterface.cpp", + "commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp", + "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", + "commands/config/interface/switchport/CmdConfigInterfaceSwitchport.cpp", + "commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.cpp", + "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", + "commands/config/l2/CmdConfigL2.cpp", + "commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp", + "commands/config/qos/CmdConfigQos.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", + "commands/config/qos/policy/CmdConfigQosPolicy.cpp", + "commands/config/qos/policy/CmdConfigQosPolicyMap.cpp", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.cpp", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", + "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.cpp", + "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp", "commands/config/rollback/CmdConfigRollback.cpp", + "commands/config/session/CmdConfigSessionClear.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", + "commands/config/session/CmdConfigSessionRebase.cpp", + "commands/config/vlan/CmdConfigVlan.cpp", + "commands/config/vlan/port/CmdConfigVlanPort.cpp", + "commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp", + "commands/config/vlan/static_mac/CmdConfigVlanStaticMac.cpp", + "commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp", + "commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp", "session/ConfigSession.cpp", + "session/Git.cpp", + "utils/InterfacesConfig.cpp", "utils/InterfaceList.cpp", ], headers = [ @@ -870,14 +894,36 @@ cpp_library( "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/interface/CmdConfigInterfaceQueuingPolicy.h", + "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h", + "commands/config/interface/pfc_config/PfcConfigUtils.h", + "commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h", + "commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h", + "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h", + "commands/config/l2/CmdConfigL2.h", + "commands/config/l2/learning_mode/CmdConfigL2LearningMode.h", "commands/config/qos/CmdConfigQos.h", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h", + "commands/config/qos/policy/CmdConfigQosPolicy.h", + "commands/config/qos/policy/CmdConfigQosPolicyMap.h", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h", + "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h", + "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h", "commands/config/rollback/CmdConfigRollback.h", + "commands/config/session/CmdConfigSessionClear.h", "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", + "commands/config/session/CmdConfigSessionRebase.h", + "commands/config/vlan/CmdConfigVlan.h", + "commands/config/vlan/port/CmdConfigVlanPort.h", + "commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h", + "commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h", + "commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h", + "commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h", "session/ConfigSession.h", + "session/Git.h", + "utils/InterfacesConfig.h", "utils/InterfaceList.h", ], exported_deps = [ diff --git a/fboss/cli/fboss2/CliAppInit.h b/fboss/cli/fboss2/CliAppInit.h new file mode 100644 index 0000000000000..0caa6261af73a --- /dev/null +++ b/fboss/cli/fboss2/CliAppInit.h @@ -0,0 +1,37 @@ +/* + * 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 "fboss/cli/fboss2/CmdGlobalOptions.h" +#include "fboss/cli/fboss2/CmdList.h" +#include "fboss/cli/fboss2/CmdSubcommands.h" + +namespace facebook::fboss::utils { + +/** + * Initialize the CLI app with global options and command tree. + * This sets up the CLI infrastructure and should be called before parsing. + * + * This function is shared between the main CLI binary and CLI E2E tests + * to ensure consistent CLI initialization. + */ +inline void initApp(CLI::App& app) { + app.require_subcommand(); + + // Initialize global options (--fmt, --host, etc.) + CmdGlobalOptions::getInstance()->init(app); + + // Initialize command tree with all available commands + CmdSubcommands::getInstance()->init( + app, kCommandTree(), kAdditionalCommandTree(), kSpecialCommands()); +} + +} // namespace facebook::fboss::utils diff --git a/fboss/cli/fboss2/CmdHandler.cpp b/fboss/cli/fboss2/CmdHandler.cpp index fbe70d798cf65..41d6307abe2fc 100644 --- a/fboss/cli/fboss2/CmdHandler.cpp +++ b/fboss/cli/fboss2/CmdHandler.cpp @@ -9,17 +9,38 @@ */ #include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/CmdArgsLists.h" #include "fboss/cli/fboss2/CmdGlobalOptions.h" +#include "fboss/cli/fboss2/CmdList.h" +#include "fboss/cli/fboss2/gen-cpp2/cli_types.h" +#include "fboss/cli/fboss2/utils/AggregateOp.h" +#include "fboss/cli/fboss2/utils/AggregateUtils.h" #include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" #include "thrift/lib/cpp/util/EnumUtils.h" #include "thrift/lib/cpp2/protocol/Serializer.h" +#include +#include +#include +#include +#include #include +#include +#include +#include #include +#include #include #include +#include +#include +#include #include #include +#include +#include +#include +#include template void printTabular( @@ -154,20 +175,10 @@ void printAggregate( namespace facebook::fboss { -static bool hasRun = false; - template void CmdHandler::runHelper() { - // Parsing library invokes every chained command handler, but we only need - // the 'leaf' command handler to be invoked. Thus, after the first (leaf) - // command handler is invoked, simply return. - // TODO: explore if the parsing library provides a better way to implement - // this. - if (hasRun) { - return; - } - - hasRun = true; + // Note: The callback wrapper in CmdSubcommands.cpp ensures that only the + // leaf command handler is invoked. It checks if get_subcommands() is empty. auto extraOptionsEC = CmdGlobalOptions::getInstance()->validateNonFilterOptions(); if (extraOptionsEC != cli::CliOptionResult::EOK) { @@ -273,11 +284,19 @@ void CmdHandler::runHelper() { } } - // Collect errors and display at end of execution + // Collect errors and display at end of execution, then throw if any failures + std::string combinedErrors; while (!executionFailures.empty()) { auto [host, errStr] = executionFailures.front(); executionFailures.pop(); XLOG(ERR) << host << " - Error in command execution: " << errStr; + if (!combinedErrors.empty()) { + combinedErrors += "; "; + } + combinedErrors += fmt::format("{}: {}", host, errStr); + } + if (!combinedErrors.empty()) { + throw std::runtime_error(combinedErrors); } } @@ -312,7 +331,9 @@ void CmdHandler::run() { runHelper(); } catch (const std::exception& e) { std::cerr << e.what() << std::endl; - exit(1); + // Rethrow so the caller can handle appropriately (e.g., tests can catch and + // check exit codes, CLI main() can exit with non-zero status) + throw; } } diff --git a/fboss/cli/fboss2/CmdHandlerImpl.cpp b/fboss/cli/fboss2/CmdHandlerImpl.cpp index 1c746291764d6..862c717ecc656 100644 --- a/fboss/cli/fboss2/CmdHandlerImpl.cpp +++ b/fboss/cli/fboss2/CmdHandlerImpl.cpp @@ -10,6 +10,8 @@ #include "fboss/cli/fboss2/CmdHandler.cpp" +// NOLINTBEGIN(misc-include-cleaner) +// IWYU pragma: begin_keep #include "fboss/cli/fboss2/commands/bounce/interface/CmdBounceInterface.h" #include "fboss/cli/fboss2/commands/clear/CmdClearArp.h" #include "fboss/cli/fboss2/commands/clear/CmdClearInterfaceCounters.h" @@ -114,6 +116,7 @@ #include "fboss/cli/fboss2/commands/show/route/CmdShowRouteDetails.h" #include "fboss/cli/fboss2/commands/show/route/CmdShowRouteSummary.h" #include "fboss/cli/fboss2/commands/show/route/gen-cpp2/model_visitation.h" +#include "fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h" #include "fboss/cli/fboss2/commands/show/sdk/dump/CmdShowSdkDump.h" #include "fboss/cli/fboss2/commands/show/systemport/CmdShowSystemPort.h" #include "fboss/cli/fboss2/commands/show/systemport/gen-cpp2/model_visitation.h" @@ -131,6 +134,8 @@ #include "fboss/lib/phy/gen-cpp2/phy_types.h" #include "fboss/lib/phy/gen-cpp2/phy_visitation.h" #include "fboss/lib/phy/gen-cpp2/prbs_visitation.h" +// NOLINTEND(misc-include-cleaner) +// IWYU pragma: end_keep namespace facebook::fboss { @@ -259,6 +264,8 @@ template void CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); +template void +CmdHandler::run(); template const ValidAggMapType CmdHandler::getValidAggs(); diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp deleted file mode 100644 index 290831c6c86be..0000000000000 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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/CmdHandler.cpp" - -// Current linter doesn't properly handle the template functions which need the -// following headers -// @lint-ignore-every CLANGTIDY facebook-unused-include-check -#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/qos/CmdConfigQos.h" -#include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.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" - -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 -CmdHandler::run(); -template void -CmdHandler::run(); -template void CmdHandler::run(); -template void -CmdHandler::run(); - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/CmdList.cpp b/fboss/cli/fboss2/CmdList.cpp index 965dd5dcfb5a6..1b42efeea0dfe 100644 --- a/fboss/cli/fboss2/CmdList.cpp +++ b/fboss/cli/fboss2/CmdList.cpp @@ -82,6 +82,7 @@ #include "fboss/cli/fboss2/commands/show/route/CmdShowRoute.h" #include "fboss/cli/fboss2/commands/show/route/CmdShowRouteDetails.h" #include "fboss/cli/fboss2/commands/show/route/CmdShowRouteSummary.h" +#include "fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h" #include "fboss/cli/fboss2/commands/show/sdk/dump/CmdShowSdkDump.h" #include "fboss/cli/fboss2/commands/show/systemport/CmdShowSystemPort.h" #include "fboss/cli/fboss2/commands/show/teflow/CmdShowTeFlow.h" @@ -225,6 +226,12 @@ const CommandTree& kCommandTree() { validFilterHandler, argTypeHandler}, + {"show", + "running-config", + "Show running configuration from the agent", + commandHandler, + argTypeHandler}, + {"show", "interface", "Show Interface information", diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 78e4ad709d3ee..324113358b50a 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -8,20 +8,39 @@ * */ +#include #include "fboss/cli/fboss2/CmdList.h" -#include "fboss/cli/fboss2/CmdHandler.h" #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/interface/CmdConfigInterfaceQueuingPolicy.h" +#include "fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h" +#include "fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h" +#include "fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h" +#include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h" +#include "fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h" +#include "fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h" #include "fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h" #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h" +#include "fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h" +#include "fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h" +#include "fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h" +#include "fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" +#include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" +#include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h" +#include "fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h" +#include "fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h" +#include "fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h" namespace facebook::fboss { @@ -46,19 +65,52 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, {{ - "description", - "Set interface description", - commandHandler, - argTypeHandler, + "pfc-config", + "Configure PFC settings for interface", + commandHandler, + argTypeHandler, }, { - "mtu", - "Set interface MTU", - commandHandler, - argTypeHandler, + "queuing-policy", + "Set queuing policy for interface", + commandHandler, + argTypeHandler, + }, + { + "switchport", + "Configure switchport settings", + commandHandler, + argTypeHandler, + {{ + "access", + "Configure access mode settings", + commandHandler, + argTypeHandler, + {{ + "vlan", + "Set access VLAN (ingressVlan) for the interface", + commandHandler, + argTypeHandler< + CmdConfigInterfaceSwitchportAccessVlanTraits>, + }}, + }}, }}, }, + { + "config", + "l2", + "Configure L2 settings", + commandHandler, + argTypeHandler, + {{ + "learning-mode", + "Set L2 learning mode (hardware, software, or disabled)", + commandHandler, + argTypeHandler, + }}, + }, + { "config", "qos", @@ -66,11 +118,45 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, {{ - "buffer-pool", - "Configure buffer pool settings", - commandHandler, - argTypeHandler, - }}, + "buffer-pool", + "Configure buffer pool settings", + commandHandler, + argTypeHandler, + }, + { + "policy", + "Configure QoS policy settings", + commandHandler, + argTypeHandler, + {{ + "map", + "Set QoS map entry (tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg)", + commandHandler, + argTypeHandler, + }}, + }, + { + "priority-group-policy", + "Configure priority group policy settings", + commandHandler, + argTypeHandler, + {{"group-id", + "Specify priority group ID (0-7)", + commandHandler, + argTypeHandler}}, + }, + { + "queuing-policy", + "Configure queuing policy settings", + commandHandler, + argTypeHandler, + {{ + "queue-id", + "Specify queue ID and attributes", + commandHandler, + argTypeHandler, + }}, + }}, }, { @@ -78,6 +164,12 @@ const CommandTree& kConfigCommandTree() { "session", "Manage config session", {{ + "clear", + "Clear the current config session", + commandHandler, + argTypeHandler, + }, + { "commit", "Commit the current config session", commandHandler, @@ -88,6 +180,12 @@ const CommandTree& kConfigCommandTree() { "Show diff between configs (session vs live, session vs revision, or revision vs revision)", commandHandler, argTypeHandler, + }, + { + "rebase", + "Rebase session changes onto current HEAD", + commandHandler, + argTypeHandler, }}, }, @@ -102,6 +200,44 @@ const CommandTree& kConfigCommandTree() { "Rollback to a previous config revision", commandHandler, argTypeHandler}, + + { + "config", + "vlan", + "Configure VLAN settings", + commandHandler, + argTypeHandler, + {{ + "port", + "Configure port settings for a VLAN", + commandHandler, + argTypeHandler, + {{ + "taggingMode", + "Set port tagging mode (tagged/untagged) for the VLAN", + commandHandler, + argTypeHandler, + }}, + }, + { + "static-mac", + "Manage static MAC entries for VLANs", + commandHandler, + argTypeHandler, + {{ + "add", + "Add a static MAC entry to a VLAN", + commandHandler, + argTypeHandler, + }, + { + "delete", + "Delete a static MAC entry from a VLAN", + commandHandler, + argTypeHandler, + }}, + }}, + }, }; sort(root.begin(), root.end()); return root; diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 24221d3d6b753..d35ecf5802691 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -10,12 +10,19 @@ #include "fboss/cli/fboss2/CmdSubcommands.h" #include "fboss/cli/fboss2/CmdArgsLists.h" +#include "fboss/cli/fboss2/CmdList.h" #include "fboss/cli/fboss2/CmdLocalOptions.h" #include "fboss/cli/fboss2/utils/CLIParserUtils.h" #include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include +#include #include +#include +#include #include +#include +#include namespace { struct singleton_tag_type {}; @@ -63,7 +70,15 @@ CLI::App* CmdSubcommands::addCommand( } auto* subCmd = app.add_subcommand(cmd.name, cmd.help); if (auto& commandHandler = cmd.commandHandler) { - subCmd->callback(*commandHandler); + // Wrap the handler to only execute if this is the leaf command + // (i.e., no subcommands were parsed). This is needed because CLI11 + // invokes callbacks for all commands in the chain, but we only want + // the target leaf command to execute. + subCmd->callback([commandHandler, subCmd]() { + if (subCmd->get_subcommands().empty()) { + (*commandHandler)(); + } + }); if (auto& localOptionsHandler = cmd.localOptionsHandler) { auto& localOptionMap = @@ -239,6 +254,88 @@ CLI::App* CmdSubcommands::addCommand( " [ ...] where is one " "of: shared-bytes, headroom-bytes, reserved-bytes"); break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID: + subCmd->add_option("vlan_id", args, "VLAN ID (1-4094)"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MAC_AND_PORT: + subCmd->add_option( + "mac_and_port", + args, + "MAC address and port name (e.g., AA:BB:CC:DD:EE:FF eth1/1/1)"); + break; + case utils::ObjectArgTypeId:: + OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_POLICY_NAME: + subCmd->add_option( + "priority_group_policy_name", args, "Priority group policy name"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_ID: + subCmd->add_option( + "group_config", + args, + " [min-limit-bytes ] [headroom-limit-bytes ] " + "[resume-offset-bytes ] [static-limit-bytes ] " + "[scaling-factor ] [buffer-pool-name ]"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_SCALING_FACTOR: + subCmd->add_option( + "scaling_factor", + args, + "MMU scaling factor (ONE, EIGHT, ONE_128TH, ONE_64TH, ONE_32TH, " + "ONE_16TH, ONE_8TH, ONE_QUARTER, ONE_HALF, TWO, FOUR, ONE_32768TH, " + "ONE_HUNDRED_TWENTY_EIGHT)"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PFC_CONFIG_ATTRS: + subCmd->add_option( + "pfc_config_attrs", + args, + " [ ...] where is one of: " + "rx, tx, rx-duration, tx-duration, priority-group-policy, " + "watchdog-detection-time, watchdog-recovery-action, " + "watchdog-recovery-time"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUING_POLICY_NAME: + subCmd->add_option( + "queuing_policy_name", args, "Queuing policy name"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUE_ID: + subCmd->add_option( + "queue_config", + args, + "Queue ID followed by key-value pairs: " + "[ ...] where is one of: reserved-bytes, " + "shared-bytes, weight, scaling-factor, scheduling, stream-type, " + "buffer-pool-name, active-queue-management"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_POLICY_NAME: + subCmd->add_option("qos_policy_name", args, "QoS policy name"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_MAP_ENTRY: + subCmd->add_option( + "map_entry", + args, + " ... where map-type is one of: " + "tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg, " + "dscp, mpls-exp, dot1p, traffic-class"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_PORT_AND_TAGGING_MODE: + subCmd->add_option( + "port_and_tagging_mode", + args, + "Port name and tagging mode (e.g., eth1/1/1 tagged|untagged)"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_L2_LEARNING_MODE: + subCmd->add_option( + "learning_mode", + args, + "L2 learning mode (hardware|software|disabled)"); + break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_INTERFACES_CONFIG: + subCmd->add_option( + "interface_config", + args, + " [ ...] where is one " + "of: description, mtu"); + break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; diff --git a/fboss/cli/fboss2/Main.cpp b/fboss/cli/fboss2/Main.cpp index 31517e45b35b8..b5ed59379b107 100644 --- a/fboss/cli/fboss2/Main.cpp +++ b/fboss/cli/fboss2/Main.cpp @@ -8,14 +8,13 @@ * */ -#include -#include "fboss/cli/fboss2/CmdGlobalOptions.h" -#include "fboss/cli/fboss2/CmdSubcommands.h" +#include +#include "fboss/cli/fboss2/CliAppInit.h" #include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" #include #include -#include +#include FOLLY_INIT_LOGGING_CONFIG("fboss=DBG0; default:async=true"); @@ -27,25 +26,15 @@ int cliMain(int argc, char* argv[]) { CLI::App app{"FBOSS CLI"}; - app.require_subcommand(); - - /* - * initialize available global options for CLI - */ - CmdGlobalOptions::getInstance()->init(app); - - /* - * initialize/build CLI command token trees - * - * NOTE: kCommandTree/kAdditionalCommandTree/kSpecialCommands will be linked - * from elsewhere to make `CmdSubcommands` an independent lib. - */ - CmdSubcommands::getInstance()->init( - app, kCommandTree(), kAdditionalCommandTree(), kSpecialCommands()); - + utils::initApp(app); utils::postAppInit(argc, argv, app); - CLI11_PARSE(app, argc, argv); + try { + CLI11_PARSE(app, argc, argv); + } catch (const std::exception& /* e */) { + // Errors are already printed to stderr by CmdHandler::run() + return 1; + } return 0; } diff --git a/fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.cpp b/fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.cpp index 9d37c76afb5c0..83ab5a3423082 100644 --- a/fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.cpp +++ b/fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.cpp @@ -9,7 +9,9 @@ */ #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" -#include + +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include #include #include @@ -67,4 +69,8 @@ void CmdConfigAppliedInfo::printOutput(const RetType& configAppliedInfo) { } } +// Explicit template instantiation +template void +CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/CmdConfigReload.cpp b/fboss/cli/fboss2/commands/config/CmdConfigReload.cpp index d49030be094e2..ad5f431262daf 100644 --- a/fboss/cli/fboss2/commands/config/CmdConfigReload.cpp +++ b/fboss/cli/fboss2/commands/config/CmdConfigReload.cpp @@ -10,6 +10,8 @@ #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" +#include "fboss/cli/fboss2/CmdHandler.cpp" + namespace facebook::fboss { CmdConfigReloadTraits::RetType CmdConfigReload::queryClient( @@ -25,4 +27,7 @@ void CmdConfigReload::printOutput(const RetType& logMsg) { std::cout << logMsg << std::endl; } +// Explicit template instantiation +template void CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp index e6a950462f6d0..6595561b80c60 100644 --- a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp +++ b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp @@ -9,120 +9,26 @@ */ #include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" -#include -#include -#include -#include + +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include -#include -#include #include -#include #include "fboss/cli/fboss2/session/ConfigSession.h" #include "fboss/cli/fboss2/utils/Table.h" -namespace fs = std::filesystem; - namespace facebook::fboss { namespace { -struct RevisionInfo { - int revisionNumber{}; - std::string owner; - int64_t commitTimeNsec{}; // Commit time in nanoseconds since epoch - std::string filePath; -}; - -// Get the username from a UID -std::string getUsername(uid_t uid) { - struct passwd* pw = getpwuid(uid); - if (pw) { - return std::string(pw->pw_name); - } - // If we can't resolve the username, return the UID as a string - return "UID:" + std::to_string(uid); -} - -// Format time as a human-readable string with milliseconds -std::string formatTime(int64_t timeNsec) { - // Convert nanoseconds to seconds and remaining nanoseconds - std::time_t timeSec = timeNsec / 1000000000; - long nsec = timeNsec % 1000000000; - - char buffer[100]; +// Format Unix timestamp (seconds) as a human-readable string +std::string formatTime(int64_t timeSec) { + char buffer[32]; tm timeinfo{}; - localtime_r(&timeSec, &timeinfo); + std::time_t time = timeSec; + localtime_r(&time, &timeinfo); std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &timeinfo); - - // Add milliseconds - long milliseconds = nsec / 1000000; - std::ostringstream oss; - oss << buffer << '.' << std::setfill('0') << std::setw(3) << milliseconds; - return oss.str(); -} - -// Collect all revision files from the CLI config directory -std::vector collectRevisions(const std::string& cliConfigDir) { - std::vector revisions; - - std::error_code ec; - if (!fs::exists(cliConfigDir, ec) || !fs::is_directory(cliConfigDir, ec)) { - // Directory doesn't exist or is not a directory - return revisions; - } - - for (const auto& entry : fs::directory_iterator(cliConfigDir, ec)) { - if (ec) { - continue; // Skip entries we can't read - } - - if (!entry.is_regular_file(ec)) { - continue; // Skip non-regular files - } - - std::string filename = entry.path().filename().string(); - int revNum = ConfigSession::extractRevisionNumber(filename); - - if (revNum < 0) { - continue; // Skip files that don't match our pattern - } - - // Get file metadata using statx to get birth time (creation time) - struct statx stx{}; - if (statx( - AT_FDCWD, entry.path().c_str(), 0, STATX_BTIME | STATX_UID, &stx) != - 0) { - continue; // Skip if we can't get file stats - } - - RevisionInfo info; - info.revisionNumber = revNum; - info.owner = getUsername(stx.stx_uid); - // Use birth time (creation time) if available, otherwise fall back to mtime - if (stx.stx_mask & STATX_BTIME) { - info.commitTimeNsec = - static_cast(stx.stx_btime.tv_sec) * 1000000000 + - stx.stx_btime.tv_nsec; - } else { - info.commitTimeNsec = - static_cast(stx.stx_mtime.tv_sec) * 1000000000 + - stx.stx_mtime.tv_nsec; - } - info.filePath = entry.path().string(); - - revisions.push_back(info); - } - - // Sort by revision number (ascending) - std::sort( - revisions.begin(), - revisions.end(), - [](const RevisionInfo& a, const RevisionInfo& b) { - return a.revisionNumber < b.revisionNumber; - }); - - return revisions; + return buffer; } } // namespace @@ -130,23 +36,27 @@ std::vector collectRevisions(const std::string& cliConfigDir) { CmdConfigHistoryTraits::RetType CmdConfigHistory::queryClient( const HostInfo& /* hostInfo */) { auto& session = ConfigSession::getInstance(); - const std::string cliConfigDir = session.getCliConfigDir(); + auto& git = session.getGit(); - auto revisions = collectRevisions(cliConfigDir); + // Get the commit history from Git for the CLI config file + auto commits = git.log(session.getCliConfigPath()); - if (revisions.empty()) { - return "No config revisions found in " + cliConfigDir; + if (commits.empty()) { + return "No config revisions found in Git history"; } // Build the table utils::Table table; - table.setHeader({"Revision", "Owner", "Commit Time"}); + table.setHeader({"Commit", "Author", "Commit Time", "Message"}); - for (const auto& rev : revisions) { + for (const auto& commit : commits) { + // Use short SHA1 (first 8 characters) + std::string shortSha = commit.sha1.substr(0, 8); table.addRow( - {"r" + std::to_string(rev.revisionNumber), - rev.owner, - formatTime(rev.commitTimeNsec)}); + {shortSha, + commit.authorName, + formatTime(commit.timestamp), + commit.subject}); } // Convert table to string @@ -159,4 +69,7 @@ void CmdConfigHistory::printOutput(const RetType& tableOutput) { std::cout << tableOutput << std::endl; } +// Explicit template instantiation +template void CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp new file mode 100644 index 0000000000000..5f9c4f3544ec9 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp @@ -0,0 +1,96 @@ +/* + * 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/CmdConfigInterface.h" + +#include +#include +#include "fboss/cli/fboss2/CmdHandler.cpp" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" + +namespace facebook::fboss { + +CmdConfigInterfaceTraits::RetType CmdConfigInterface::queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& interfaceConfig) { + const auto& interfaces = interfaceConfig.getInterfaces(); + const auto& attributes = interfaceConfig.getAttributes(); + + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + + // If no attributes provided, this is a pass-through to subcommands + if (!interfaceConfig.hasAttributes()) { + throw std::runtime_error( + "Incomplete command. Either provide attributes (description, mtu) " + "or use a subcommand (switchport)"); + } + + std::vector results; + + // Process each attribute + for (const auto& [attr, value] : attributes) { + if (attr == "description") { + // Set description for all ports + for (const utils::Intf& intf : interfaces) { + cfg::Port* port = intf.getPort(); + if (port) { + port->description() = value; + } + } + results.push_back(fmt::format("description=\"{}\"", value)); + } else if (attr == "mtu") { + // Validate and set MTU for all interfaces + int32_t mtu = 0; + try { + mtu = folly::to(value); + } catch (const std::exception&) { + throw std::invalid_argument( + fmt::format("Invalid MTU value '{}': must be an integer", value)); + } + + if (mtu < utils::kMtuMin || mtu > utils::kMtuMax) { + throw std::invalid_argument( + fmt::format( + "MTU value {} is out of range. Valid range is {}-{}", + mtu, + utils::kMtuMin, + utils::kMtuMax)); + } + + for (const utils::Intf& intf : interfaces) { + cfg::Interface* interface = intf.getInterface(); + if (interface) { + interface->mtu() = mtu; + } + } + results.push_back(fmt::format("mtu={}", mtu)); + } + } + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + std::string attrList = folly::join(", ", results); + return fmt::format( + "Successfully configured interface(s) {}: {}", interfaceList, attrList); +} + +void CmdConfigInterface::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h index 5550bfae6f22a..2758fbb0e0082 100644 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h @@ -11,28 +11,28 @@ #pragma once #include "fboss/cli/fboss2/CmdHandler.h" -#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.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; + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_INTERFACES_CONFIG; + using ObjectArgType = utils::InterfacesConfig; using RetType = std::string; }; class CmdConfigInterface : public CmdHandler { public: + using ObjectArgType = CmdConfigInterfaceTraits::ObjectArgType; + using RetType = CmdConfigInterfaceTraits::RetType; + RetType queryClient( - const HostInfo& /* hostInfo */, - const ObjectArgType& /* interfaceNames */) { - throw std::runtime_error( - "Incomplete command, please use one of the subcommands"); - } + const HostInfo& hostInfo, + const ObjectArgType& interfaceConfig); - void printOutput(const RetType& /* model */) {} + void printOutput(const RetType& logMsg); }; } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp deleted file mode 100644 index c2b0d4cf8ce9d..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 - description changes are hitless - ConfigSession::getInstance().saveConfig( - cli::ServiceType::AGENT, cli::ConfigActionLevel::HITLESS); - - 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/CmdConfigInterfaceMtu.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp deleted file mode 100644 index d9c1b2e837db9..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 - MTU changes are hitless - ConfigSession::getInstance().saveConfig( - cli::ServiceType::AGENT, cli::ConfigActionLevel::HITLESS); - - 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 deleted file mode 100644 index a60d433f99949..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 -#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 < utils::kMtuMin || mtu > utils::kMtuMax) { - throw std::invalid_argument( - fmt::format( - "MTU must be between {} and {} inclusive, got: {}", - utils::kMtuMin, - utils::kMtuMax, - mtu)); - } - data_.push_back(mtu); - } catch (const folly::ConversionError&) { - 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/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp new file mode 100644 index 0000000000000..0806922e32026 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp @@ -0,0 +1,85 @@ +/* + * 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/CmdConfigInterfaceQueuingPolicy.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include +#include +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" + +namespace facebook::fboss { + +CmdConfigInterfaceQueuingPolicyTraits::RetType +CmdConfigInterfaceQueuingPolicy::queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfacesConfig& interfaceConfig, + const ObjectArgType& policyNameArg) { + const auto& interfaces = interfaceConfig.getInterfaces(); + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + + if (policyNameArg.data().empty()) { + throw std::invalid_argument("No queuing policy name provided"); + } + + std::string policyName = policyNameArg.data()[0]; + + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + // Check that the policy exists in portQueueConfigs + const auto& portQueueConfigs = *switchConfig.portQueueConfigs(); + if (portQueueConfigs.find(policyName) == portQueueConfigs.end()) { + throw std::invalid_argument( + fmt::format("Queuing policy '{}' does not exist.", policyName)); + } + + // Update portQueueConfigName for all resolved ports + for (const utils::Intf& intf : interfaces) { + cfg::Port* port = intf.getPort(); + if (!port) { + throw std::invalid_argument( + fmt::format("Interface '{}' is not a physical port.", intf.name())); + } + port->portQueueConfigName() = policyName; + } + + // Save the updated config + session.saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + return fmt::format( + "Successfully set queuing-policy '{}' for interface(s) {}", + policyName, + interfaceList); +} + +void CmdConfigInterfaceQueuingPolicy::printOutput( + const CmdConfigInterfaceQueuingPolicyTraits::RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigInterfaceQueuingPolicy, + CmdConfigInterfaceQueuingPolicyTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h similarity index 58% rename from fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h rename to fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h index 2858dc85aee6f..5221e15ce5f65 100644 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h @@ -10,14 +10,17 @@ #pragma once +#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" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { -struct CmdConfigInterfaceDescriptionTraits : public WriteCommandTraits { +struct CmdConfigInterfaceQueuingPolicyTraits : public WriteCommandTraits { using ParentCmd = CmdConfigInterface; static constexpr utils::ObjectArgTypeId ObjectArgTypeId = utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; @@ -25,17 +28,18 @@ struct CmdConfigInterfaceDescriptionTraits : public WriteCommandTraits { using RetType = std::string; }; -class CmdConfigInterfaceDescription : public CmdHandler< - CmdConfigInterfaceDescription, - CmdConfigInterfaceDescriptionTraits> { +class CmdConfigInterfaceQueuingPolicy + : public CmdHandler< + CmdConfigInterfaceQueuingPolicy, + CmdConfigInterfaceQueuingPolicyTraits> { public: - using ObjectArgType = CmdConfigInterfaceDescriptionTraits::ObjectArgType; - using RetType = CmdConfigInterfaceDescriptionTraits::RetType; + using ObjectArgType = CmdConfigInterfaceQueuingPolicyTraits::ObjectArgType; + using RetType = CmdConfigInterfaceQueuingPolicyTraits::RetType; RetType queryClient( const HostInfo& hostInfo, - const utils::InterfaceList& interfaces, - const ObjectArgType& description); + const utils::InterfacesConfig& interfaceConfig, + const ObjectArgType& policyName); void printOutput(const RetType& logMsg); }; diff --git a/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp new file mode 100644 index 0000000000000..31f654d4c66c3 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp @@ -0,0 +1,192 @@ +/* + * 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/pfc_config/CmdConfigInterfacePfcConfig.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" + +namespace facebook::fboss { + +namespace utils { + +namespace { + +const std::set kValidAttrs = { + "rx", + "tx", + "rx-duration", + "tx-duration", + "priority-group-policy", + "watchdog-detection-time", + "watchdog-recovery-action", + "watchdog-recovery-time", +}; + +bool parseEnabledDisabled(const std::string& value) { + std::string lower = value; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + if (lower == "enabled") { + return true; + } else if (lower == "disabled") { + return false; + } + throw std::invalid_argument( + fmt::format( + "Invalid value '{}': expected 'enabled' or 'disabled'", value)); +} + +int32_t parseMsec(const std::string& value) { + try { + int32_t msec = folly::to(value); + if (msec < 0) { + throw std::invalid_argument( + fmt::format("Millisecond value must be non-negative, got: {}", msec)); + } + return msec; + } catch (const folly::ConversionError&) { + throw std::invalid_argument( + fmt::format("Invalid millisecond value: {}", value)); + } +} + +cfg::PfcWatchdogRecoveryAction parseRecoveryAction(const std::string& value) { + std::string lower = value; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + if (lower == "drop") { + return cfg::PfcWatchdogRecoveryAction::DROP; + } else if (lower == "no-drop") { + return cfg::PfcWatchdogRecoveryAction::NO_DROP; + } + throw std::invalid_argument( + fmt::format( + "Invalid recovery action '{}': expected 'drop' or 'no-drop'", value)); +} + +} // namespace + +PfcConfigAttrs::PfcConfigAttrs(std::vector v) { + if (v.empty()) { + throw std::invalid_argument( + "Expected: [ ...] where is one of: " + "rx, tx, rx-duration, tx-duration, priority-group-policy, " + "watchdog-detection-time, watchdog-recovery-action, watchdog-recovery-time"); + } + + // Parse key-value pairs + if (v.size() % 2 != 0) { + throw std::invalid_argument( + "Attribute-value pairs must come in pairs. Got odd number of arguments."); + } + + for (size_t i = 0; i < v.size(); i += 2) { + const std::string& attr = v[i]; + const std::string& value = v[i + 1]; + + if (kValidAttrs.find(attr) == kValidAttrs.end()) { + throw std::invalid_argument( + fmt::format( + "Unknown attribute: '{}'. Valid attributes are: {}", + attr, + folly::join(", ", kValidAttrs))); + } + + attributes_.emplace_back(attr, value); + data_.push_back(attr); + data_.push_back(value); + } +} + +} // namespace utils + +CmdConfigInterfacePfcConfigTraits::RetType +CmdConfigInterfacePfcConfig::queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfacesConfig& interfaceConfig, + const ObjectArgType& config) { + const auto& interfaces = interfaceConfig.getInterfaces(); + for (const utils::Intf& intf : interfaces) { + cfg::Port* port = intf.getPort(); + if (!port) { + throw std::runtime_error( + fmt::format("Port not found for interface {}", intf.name())); + } + // Ensure pfc struct exists + if (!port->pfc().has_value()) { + port->pfc() = cfg::PortPfc(); + } + + // Process each attribute-value pair + for (const auto& [attr, value] : config.getAttributes()) { + if (attr == "rx") { + port->pfc()->rx() = utils::parseEnabledDisabled(value); + } else if (attr == "tx") { + port->pfc()->tx() = utils::parseEnabledDisabled(value); + } else if (attr == "rx-duration") { + port->pfc()->rxPfcDurationEnable() = utils::parseEnabledDisabled(value); + } else if (attr == "tx-duration") { + port->pfc()->txPfcDurationEnable() = utils::parseEnabledDisabled(value); + } else if (attr == "priority-group-policy") { + port->pfc()->portPgConfigName() = value; + } else if (attr == "watchdog-detection-time") { + if (!port->pfc()->watchdog().has_value()) { + port->pfc()->watchdog() = cfg::PfcWatchdog(); + } + port->pfc()->watchdog()->detectionTimeMsecs() = utils::parseMsec(value); + } else if (attr == "watchdog-recovery-action") { + if (!port->pfc()->watchdog().has_value()) { + port->pfc()->watchdog() = cfg::PfcWatchdog(); + } + port->pfc()->watchdog()->recoveryAction() = + utils::parseRecoveryAction(value); + } else if (attr == "watchdog-recovery-time") { + if (!port->pfc()->watchdog().has_value()) { + port->pfc()->watchdog() = cfg::PfcWatchdog(); + } + port->pfc()->watchdog()->recoveryTimeMsecs() = utils::parseMsec(value); + } + } + } + + ConfigSession::getInstance().saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + return fmt::format( + "Successfully configured PFC for interface(s) {}", interfaceList); +} + +void CmdConfigInterfacePfcConfig::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigInterfacePfcConfig, + CmdConfigInterfacePfcConfigTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h new file mode 100644 index 0000000000000..9190af2b9b9dc --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h @@ -0,0 +1,46 @@ +/* + * 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 "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" + +namespace facebook::fboss { + +struct CmdConfigInterfacePfcConfigTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterface; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PFC_CONFIG_ATTRS; + using ObjectArgType = utils::PfcConfigAttrs; + using RetType = std::string; +}; + +class CmdConfigInterfacePfcConfig : public CmdHandler< + CmdConfigInterfacePfcConfig, + CmdConfigInterfacePfcConfigTraits> { + public: + using ObjectArgType = CmdConfigInterfacePfcConfigTraits::ObjectArgType; + using RetType = CmdConfigInterfacePfcConfigTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const utils::InterfacesConfig& interfaceConfig, + const ObjectArgType& config); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h b/fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h new file mode 100644 index 0000000000000..78564d4833f87 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.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 +#include +#include + +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" + +namespace facebook::fboss::utils { + +// Custom type for PFC config key-value pairs +// Parses: [ ...] +// where attr is one of: rx, tx, rx-duration, tx-duration, +// priority-group-policy, watchdog-detection-time, watchdog-recovery-action, +// watchdog-recovery-time +class PfcConfigAttrs : public BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ PfcConfigAttrs(std::vector v); + + const std::vector>& getAttributes() + const { + return attributes_; + } + + const static ObjectArgTypeId id = + ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PFC_CONFIG_ATTRS; + + private: + std::vector> attributes_; +}; + +} // namespace facebook::fboss::utils diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.cpp b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.cpp new file mode 100644 index 0000000000000..19057f8b3878f --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.cpp @@ -0,0 +1,22 @@ +/* + * 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/switchport/CmdConfigInterfaceSwitchport.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler< + CmdConfigInterfaceSwitchport, + CmdConfigInterfaceSwitchportTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h new file mode 100644 index 0000000000000..ef254bada99e5 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h @@ -0,0 +1,46 @@ +/* + * 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/InterfacesConfig.h" + +namespace facebook::fboss { + +struct CmdConfigInterfaceSwitchportTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterface; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdConfigInterfaceSwitchport : public CmdHandler< + CmdConfigInterfaceSwitchport, + CmdConfigInterfaceSwitchportTraits> { + public: + RetType queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfacesConfig& interfaceConfig) { + // Get the interfaces from the config (ignoring any attributes) + const auto& interfaces = interfaceConfig.getInterfaces(); + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + 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/switchport/access/CmdConfigInterfaceSwitchportAccess.cpp b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.cpp new file mode 100644 index 0000000000000..c84ff78ec7938 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.cpp @@ -0,0 +1,22 @@ +/* + * 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/switchport/access/CmdConfigInterfaceSwitchportAccess.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler< + CmdConfigInterfaceSwitchportAccess, + CmdConfigInterfaceSwitchportAccessTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h new file mode 100644 index 0000000000000..3306f9de84779 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h @@ -0,0 +1,47 @@ +/* + * 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/switchport/CmdConfigInterfaceSwitchport.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" + +namespace facebook::fboss { + +struct CmdConfigInterfaceSwitchportAccessTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterfaceSwitchport; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdConfigInterfaceSwitchportAccess + : public CmdHandler< + CmdConfigInterfaceSwitchportAccess, + CmdConfigInterfaceSwitchportAccessTraits> { + public: + RetType queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfacesConfig& interfaceConfig) { + // Get the interfaces from the config (ignoring any attributes) + const auto& interfaces = interfaceConfig.getInterfaces(); + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + 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/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp new file mode 100644 index 0000000000000..9bfa8ffff58ca --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp @@ -0,0 +1,61 @@ +/* + * 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/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +CmdConfigInterfaceSwitchportAccessVlanTraits::RetType +CmdConfigInterfaceSwitchportAccessVlan::queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfacesConfig& interfaceConfig, + const CmdConfigInterfaceSwitchportAccessVlanTraits::ObjectArgType& + vlanIdValue) { + const auto& interfaces = interfaceConfig.getInterfaces(); + if (interfaces.empty()) { + throw std::invalid_argument("No interface name provided"); + } + + // Extract the VLAN ID (validation already done in VlanIdValue constructor) + int32_t vlanId = vlanIdValue.getVlanId(); + + // Update ingressVlan for all resolved ports + for (const utils::Intf& intf : interfaces) { + cfg::Port* port = intf.getPort(); + if (port) { + port->ingressVlan() = vlanId; + } + } + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + std::string interfaceList = folly::join(", ", interfaces.getNames()); + std::string message = "Successfully set access VLAN for interface(s) " + + interfaceList + " to " + std::to_string(vlanId); + + return message; +} + +void CmdConfigInterfaceSwitchportAccessVlan::printOutput( + const CmdConfigInterfaceSwitchportAccessVlanTraits::RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigInterfaceSwitchportAccessVlan, + CmdConfigInterfaceSwitchportAccessVlanTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h new file mode 100644 index 0000000000000..f71779461af8a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h @@ -0,0 +1,49 @@ +/* + * 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/switchport/access/CmdConfigInterfaceSwitchportAccess.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" + +namespace facebook::fboss { + +// Use VlanIdValue from CmdUtils.h +using VlanIdValue = utils::VlanIdValue; + +struct CmdConfigInterfaceSwitchportAccessVlanTraits + : public WriteCommandTraits { + using ParentCmd = CmdConfigInterfaceSwitchportAccess; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID; + using ObjectArgType = VlanIdValue; + using RetType = std::string; +}; + +class CmdConfigInterfaceSwitchportAccessVlan + : public CmdHandler< + CmdConfigInterfaceSwitchportAccessVlan, + CmdConfigInterfaceSwitchportAccessVlanTraits> { + public: + using ObjectArgType = + CmdConfigInterfaceSwitchportAccessVlanTraits::ObjectArgType; + using RetType = CmdConfigInterfaceSwitchportAccessVlanTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const utils::InterfacesConfig& interfaceConfig, + const ObjectArgType& vlanId); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.cpp b/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.cpp new file mode 100644 index 0000000000000..48162e57e94cf --- /dev/null +++ b/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.cpp @@ -0,0 +1,20 @@ +/* + * 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/l2/CmdConfigL2.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h b/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h new file mode 100644 index 0000000000000..783102e2d7368 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h @@ -0,0 +1,37 @@ +/* + * 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" + +namespace facebook::fboss { + +struct CmdConfigL2Traits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = utils::NoneArgType; + using RetType = std::string; +}; + +class CmdConfigL2 : public CmdHandler { + public: + using ObjectArgType = CmdConfigL2Traits::ObjectArgType; + using RetType = CmdConfigL2Traits::RetType; + + RetType queryClient(const HostInfo& /* hostInfo */) { + throw std::runtime_error( + "Incomplete command, please use 'learning-mode' subcommand"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp b/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp new file mode 100644 index 0000000000000..9a74b92719e23 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp @@ -0,0 +1,101 @@ +/* + * 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/l2/learning_mode/CmdConfigL2LearningMode.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +L2LearningModeArg::L2LearningModeArg(std::vector v) { + if (v.empty()) { + throw std::invalid_argument( + "L2 learning mode is required (hardware, software, or disabled)"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected exactly one L2 learning mode (hardware, software, or disabled)"); + } + + std::string mode = v[0]; + folly::toLowerAscii(mode); + if (mode == "hardware") { + l2LearningMode_ = cfg::L2LearningMode::HARDWARE; + } else if (mode == "software") { + l2LearningMode_ = cfg::L2LearningMode::SOFTWARE; + } else if (mode == "disabled") { + l2LearningMode_ = cfg::L2LearningMode::DISABLED; + } else { + throw std::invalid_argument( + "Invalid L2 learning mode '" + v[0] + + "'. Expected 'hardware', 'software', or 'disabled'"); + } + data_.push_back(v[0]); +} + +namespace { +std::string l2LearningModeToString(cfg::L2LearningMode mode) { + switch (mode) { + case cfg::L2LearningMode::HARDWARE: + return "hardware"; + case cfg::L2LearningMode::SOFTWARE: + return "software"; + case cfg::L2LearningMode::DISABLED: + return "disabled"; + } + folly::assume_unreachable(); +} +} // namespace + +CmdConfigL2LearningModeTraits::RetType CmdConfigL2LearningMode::queryClient( + const HostInfo& hostInfo, + const ObjectArgType& learningMode) { + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + + cfg::L2LearningMode newMode = learningMode.getL2LearningMode(); + + // Get current mode for informational purposes + cfg::L2LearningMode currentMode = + *swConfig.switchSettings()->l2LearningMode(); + + if (currentMode == newMode) { + return fmt::format( + "L2 learning mode is already set to '{}'", + l2LearningModeToString(newMode)); + } + + // Update the l2LearningMode in switchSettings + swConfig.switchSettings()->l2LearningMode() = newMode; + + // Save the updated config - L2 learning mode changes require agent restart + ConfigSession::getInstance().saveConfig( + cli::ConfigActionLevel::AGENT_RESTART); + + return fmt::format( + "Successfully set L2 learning mode to '{}'", + l2LearningModeToString(newMode)); +} + +void CmdConfigL2LearningMode::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h b/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h new file mode 100644 index 0000000000000..6a31bdcc29f25 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h @@ -0,0 +1,58 @@ +/* + * 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/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h" + +namespace facebook::fboss { + +// Custom type for L2 learning mode argument with validation +class L2LearningModeArg : public utils::BaseObjectArgType { + public: + /* implicit */ L2LearningModeArg( // NOLINT(google-explicit-constructor) + std::vector v); + + cfg::L2LearningMode getL2LearningMode() const { + return l2LearningMode_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_L2_LEARNING_MODE; + + private: + cfg::L2LearningMode l2LearningMode_ = cfg::L2LearningMode::HARDWARE; +}; + +struct CmdConfigL2LearningModeTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigL2; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_L2_LEARNING_MODE; + using ObjectArgType = L2LearningModeArg; + using RetType = std::string; +}; + +class CmdConfigL2LearningMode : public CmdHandler< + CmdConfigL2LearningMode, + CmdConfigL2LearningModeTraits> { + public: + using ObjectArgType = CmdConfigL2LearningModeTraits::ObjectArgType; + using RetType = CmdConfigL2LearningModeTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const ObjectArgType& learningMode); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.cpp b/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.cpp new file mode 100644 index 0000000000000..972fb25b8ab39 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.cpp @@ -0,0 +1,20 @@ +/* + * 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/qos/CmdConfigQos.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp index e01ec275b2a99..eac393edd6fae 100644 --- a/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp +++ b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp @@ -10,6 +10,8 @@ #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h" +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include #include #include @@ -153,4 +155,8 @@ void CmdConfigQosBufferPool::printOutput(const RetType& logMsg) { std::cout << logMsg << std::endl; } +// Explicit template instantiation +template void +CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.cpp b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.cpp new file mode 100644 index 0000000000000..b12d2d04d3ea7 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.cpp @@ -0,0 +1,20 @@ +/* + * 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/qos/policy/CmdConfigQosPolicy.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h new file mode 100644 index 0000000000000..4c46261ba8c5a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h @@ -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. + * + */ + +#pragma once + +#include +#include +#include +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +/** + * Custom type for QoS policy name. + */ +class QosPolicyName : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ QosPolicyName(std::vector v) + : BaseObjectArgType(std::move(v)) {} + + std::string getName() const { + return data_.empty() ? "" : data_[0]; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_POLICY_NAME; +}; + +struct CmdConfigQosPolicyTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQos; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_POLICY_NAME; + using ObjectArgType = QosPolicyName; + using RetType = std::string; +}; + +class CmdConfigQosPolicy + : public CmdHandler { + public: + using ObjectArgType = CmdConfigQosPolicyTraits::ObjectArgType; + using RetType = CmdConfigQosPolicyTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& /* policyName */) { + throw std::runtime_error( + "Incomplete command, please use one of the subcommands: map"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp new file mode 100644 index 0000000000000..e34e87b4ff256 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp @@ -0,0 +1,440 @@ +/* + * 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/qos/policy/CmdConfigQosPolicyMap.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +namespace { + +constexpr int16_t kMinTCValue = 0; +constexpr int16_t kMaxTCValue = 7; +// DSCP: 6-bit field (RFC 2474) +constexpr int8_t kMinDscpValue = 0; +constexpr int8_t kMaxDscpValue = 63; +// MPLS EXP/TC: 3-bit field (RFC 3032, RFC 5462) +constexpr int8_t kMinExpValue = 0; +constexpr int8_t kMaxExpValue = 7; +// PCP/DOT1P: 3-bit field (IEEE 802.1Q) +constexpr int8_t kMinPcpValue = 0; +constexpr int8_t kMaxPcpValue = 7; + +std::string getMapTypeString(QosMapType mapType, QosMapDirection direction) { + switch (mapType) { + case QosMapType::TC_TO_QUEUE: + return "trafficClassToQueueId"; + case QosMapType::PFC_PRI_TO_QUEUE: + return "pfcPriorityToQueueId"; + case QosMapType::TC_TO_PG: + return "trafficClassToPgId"; + case QosMapType::PFC_PRI_TO_PG: + return "pfcPriorityToPgId"; + case QosMapType::DSCP: + return direction == QosMapDirection::INGRESS + ? "dscpMaps.fromDscpToTrafficClass" + : "dscpMaps.fromTrafficClassToDscp"; + case QosMapType::MPLS_EXP: + return direction == QosMapDirection::INGRESS + ? "expMaps.fromExpToTrafficClass" + : "expMaps.fromTrafficClassToExp"; + case QosMapType::DOT1P: + return direction == QosMapDirection::INGRESS + ? "pcpMaps.fromPcpToTrafficClass" + : "pcpMaps.fromTrafficClassToPcp"; + } + folly::assume_unreachable(); +} + +// Validates value range based on type token and returns the map type. +QosMapType validateAndGetMapType(const std::string& typeToken, int16_t value) { + if (typeToken == "dscp") { + if (value < kMinDscpValue || value > kMaxDscpValue) { + throw std::invalid_argument( + fmt::format( + "DSCP value must be between {} and {}, got: {}", + kMinDscpValue, + kMaxDscpValue, + value)); + } + return QosMapType::DSCP; + } else if (typeToken == "mpls-exp") { + if (value < kMinExpValue || value > kMaxExpValue) { + throw std::invalid_argument( + fmt::format( + "MPLS EXP value must be between {} and {}, got: {}", + kMinExpValue, + kMaxExpValue, + value)); + } + return QosMapType::MPLS_EXP; + } else if (typeToken == "dot1p") { + if (value < kMinPcpValue || value > kMaxPcpValue) { + throw std::invalid_argument( + fmt::format( + "DOT1P value must be between {} and {}, got: {}", + kMinPcpValue, + kMaxPcpValue, + value)); + } + return QosMapType::DOT1P; + } + folly::assume_unreachable(); +} + +// Helper to update a QoS map entry (DSCP/EXP/DOT1P). +// - mapsRef: reference to the list of map entries +// - config: the QosMapConfig containing trafficClass, value, and direction +// - getIngressList: lambda to get the ingress list from an entry +// - setEgressValue: lambda to set the egress value on an entry +template +void updateQosMapEntry( + std::vector& mapsRef, + const QosMapConfig& config, + GetIngressList getIngressList, + SetEgressValue setEgressValue) { + int16_t trafficClass = config.getTrafficClass(); + // Find or create entry with matching internalTrafficClass + MapEntry* entry = nullptr; + for (auto& m : mapsRef) { + if (*m.internalTrafficClass() == trafficClass) { + entry = &m; + break; + } + } + if (entry == nullptr) { // Entry not found, create a new one + MapEntry newEntry; + newEntry.internalTrafficClass() = trafficClass; + mapsRef.push_back(std::move(newEntry)); + entry = &mapsRef.back(); + } + if (config.getDirection() == QosMapDirection::INGRESS) { + auto& fromList = getIngressList(*entry); + auto byteVal = static_cast(config.getValue()); + if (std::find(fromList.begin(), fromList.end(), byteVal) == + fromList.end()) { + fromList.push_back(byteVal); + } + } else { + setEgressValue(*entry, static_cast(config.getValue())); + } +} + +} // namespace + +QosMapConfig::QosMapConfig(std::vector v) { + if (v.empty()) { + throw std::invalid_argument( + "Expected: ... where map-type is one of: " + "tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg, " + "dscp, mpls-exp, dot1p, traffic-class"); + } + + const auto& firstToken = v[0]; + + // Check for simple map types first + if (firstToken == "tc-to-queue") { + mapType_ = QosMapType::TC_TO_QUEUE; + } else if (firstToken == "pfc-pri-to-queue") { + mapType_ = QosMapType::PFC_PRI_TO_QUEUE; + } else if (firstToken == "tc-to-pg") { + mapType_ = QosMapType::TC_TO_PG; + } else if (firstToken == "pfc-pri-to-pg") { + mapType_ = QosMapType::PFC_PRI_TO_PG; + } else if ( + firstToken == "dscp" || firstToken == "mpls-exp" || + firstToken == "dot1p" || firstToken == "traffic-class") { + // Handle DSCP/EXP/DOT1P maps with a different syntax + parseListMapType(v); + return; + } else { + throw std::invalid_argument( + fmt::format( + "Invalid map type: '{}'. Valid values are: " + "tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg, " + "dscp, mpls-exp, dot1p, traffic-class", + firstToken)); + } + + // Simple map types: + data_.push_back(firstToken); + + if (v.size() < 3) { + throw std::invalid_argument( + fmt::format("Expected: {} ", firstToken)); + } + + // Parse the key + key_ = folly::to(v[1]); + if (key_ < kMinTCValue || key_ > kMaxTCValue) { + throw std::invalid_argument( + fmt::format( + "Key must be between {} and {}, got: {}", + kMinTCValue, + kMaxTCValue, + key_)); + } + data_.push_back(v[1]); + + // Parse the value + value_ = folly::to(v[2]); + if (value_ < kMinTCValue || value_ > kMaxTCValue) { + throw std::invalid_argument( + fmt::format( + "Value must be between {} and {}, got: {}", + kMinTCValue, + kMaxTCValue, + value_)); + } + data_.push_back(v[2]); +} + +bool QosMapConfig::isListMapType() const { + return mapType_ == QosMapType::DSCP || mapType_ == QosMapType::MPLS_EXP || + mapType_ == QosMapType::DOT1P; +} + +void QosMapConfig::parseListMapType(const std::vector& v) { + // Syntax for DSCP/EXP/DOT1P maps: + // Ingress (X -> TC, additive): + // dscp traffic-class + // mpls-exp traffic-class + // dot1p traffic-class + // Egress (TC -> X): + // traffic-class dscp + // traffic-class mpls-exp + // traffic-class dot1p + + const auto& firstToken = v[0]; + + if (v.size() < 4) { + if (firstToken == "traffic-class") { + throw std::invalid_argument( + "Expected: traffic-class " + "where type is one of: dscp, mpls-exp, dot1p"); + } else { + throw std::invalid_argument( + fmt::format("Expected: {} traffic-class ", firstToken)); + } + } + + if (firstToken == "traffic-class") { + // Egress direction: traffic-class + // Valid types are: dscp, mpls-exp, dot1p + direction_ = QosMapDirection::EGRESS; + trafficClass_ = folly::to(v[1]); + if (trafficClass_ < kMinTCValue || trafficClass_ > kMaxTCValue) { + throw std::invalid_argument( + fmt::format( + "Traffic class must be between {} and {}, got: {}", + kMinTCValue, + kMaxTCValue, + trafficClass_)); + } + + const auto& typeToken = v[2]; + // Validate that the type token is a valid list map type (dscp, mpls-exp, + // dot1p) before parsing. This prevents "queue-id" and other tokens from + // being incorrectly processed as list maps. + if (typeToken != "dscp" && typeToken != "mpls-exp" && + typeToken != "dot1p") { + throw std::invalid_argument( + fmt::format( + "Invalid map type '{}' after 'traffic-class '. " + "Valid types are: dscp, mpls-exp, dot1p. " + "For traffic-class to queue mappings, use 'tc-to-queue' instead.", + typeToken)); + } + value_ = folly::to(v[3]); + mapType_ = validateAndGetMapType(typeToken, value_); + } else { + // Ingress direction: traffic-class + direction_ = QosMapDirection::INGRESS; + value_ = folly::to(v[1]); + + if (v[2] != "traffic-class") { + throw std::invalid_argument( + fmt::format( + "Expected 'traffic-class' after '{} ', got '{}'", + firstToken, + v[2])); + } + + trafficClass_ = folly::to(v[3]); + if (trafficClass_ < kMinTCValue || trafficClass_ > kMaxTCValue) { + throw std::invalid_argument( + fmt::format( + "Traffic class must be between {} and {}, got: {}", + kMinTCValue, + kMaxTCValue, + trafficClass_)); + } + + mapType_ = validateAndGetMapType(firstToken, value_); + } + + // Populate data_ for display purposes + for (const auto& token : v) { + data_.push_back(token); + } +} + +CmdConfigQosPolicyMapTraits::RetType CmdConfigQosPolicyMap::queryClient( + const HostInfo& /* hostInfo */, + const QosPolicyName& policyName, + const ObjectArgType& config) { + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + const std::string& name = policyName.getName(); + auto& qosPolicies = *switchConfig.qosPolicies(); + + // Find or create the QosPolicy with the given name + cfg::QosPolicy* targetPolicy = nullptr; + for (auto& policy : qosPolicies) { + if (*policy.name() == name) { + targetPolicy = &policy; + break; + } + } + + if (targetPolicy == nullptr) { + // Create a new QosPolicy + cfg::QosPolicy newPolicy; + newPolicy.name() = name; + qosPolicies.push_back(std::move(newPolicy)); + targetPolicy = &qosPolicies.back(); + } + + // Ensure qosMap is initialized + if (!targetPolicy->qosMap().has_value()) { + targetPolicy->qosMap() = cfg::QosMap(); + } + auto& qosMap = *targetPolicy->qosMap(); + + // Set the appropriate map entry based on map type + if (config.isListMapType()) { + // Handle DSCP/EXP/DOT1P maps + switch (config.getMapType()) { + case QosMapType::DSCP: + updateQosMapEntry( + *qosMap.dscpMaps(), + config, + [](cfg::DscpQosMap& e) -> auto& { + return *e.fromDscpToTrafficClass(); + }, + [](cfg::DscpQosMap& e, int8_t v) { + e.fromTrafficClassToDscp() = v; + }); + break; + case QosMapType::MPLS_EXP: + updateQosMapEntry( + *qosMap.expMaps(), + config, + [](cfg::ExpQosMap& e) -> auto& { + return *e.fromExpToTrafficClass(); + }, + [](cfg::ExpQosMap& e, int8_t v) { e.fromTrafficClassToExp() = v; }); + break; + case QosMapType::DOT1P: + // pcpMaps is optional + if (!qosMap.pcpMaps().has_value()) { + qosMap.pcpMaps() = std::vector(); + } + updateQosMapEntry( + *qosMap.pcpMaps(), + config, + [](cfg::PcpQosMap& e) -> auto& { + return *e.fromPcpToTrafficClass(); + }, + [](cfg::PcpQosMap& e, int8_t v) { e.fromTrafficClassToPcp() = v; }); + break; + default: + break; + } + + session.saveConfig(); + + return fmt::format( + "Successfully set QoS policy '{}' {} [tc={}] = {}", + name, + getMapTypeString(config.getMapType(), config.getDirection()), + config.getTrafficClass(), + config.getValue()); + } else { + // Handle simple map types + int16_t key = config.getKey(); + int16_t value = config.getValue(); + + switch (config.getMapType()) { + case QosMapType::TC_TO_QUEUE: + (*qosMap.trafficClassToQueueId())[key] = value; + break; + case QosMapType::PFC_PRI_TO_QUEUE: + if (!qosMap.pfcPriorityToQueueId().has_value()) { + qosMap.pfcPriorityToQueueId() = std::map(); + } + (*qosMap.pfcPriorityToQueueId())[key] = value; + break; + case QosMapType::TC_TO_PG: + if (!qosMap.trafficClassToPgId().has_value()) { + qosMap.trafficClassToPgId() = std::map(); + } + (*qosMap.trafficClassToPgId())[key] = value; + break; + case QosMapType::PFC_PRI_TO_PG: + if (!qosMap.pfcPriorityToPgId().has_value()) { + qosMap.pfcPriorityToPgId() = std::map(); + } + (*qosMap.pfcPriorityToPgId())[key] = value; + break; + default: + break; + } + + session.saveConfig(); + + return fmt::format( + "Successfully set QoS policy '{}' {} [{}] = {}", + name, + getMapTypeString(config.getMapType(), config.getDirection()), + key, + value); + } +} + +void CmdConfigQosPolicyMap::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h new file mode 100644 index 0000000000000..fe11bad57e50a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h @@ -0,0 +1,134 @@ +/* + * 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 +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +/** + * Enum representing which QosMap to modify. + */ +enum class QosMapType { + TC_TO_QUEUE, // trafficClassToQueueId + PFC_PRI_TO_QUEUE, // pfcPriorityToQueueId + TC_TO_PG, // trafficClassToPgId + PFC_PRI_TO_PG, // pfcPriorityToPgId + DSCP, // dscpMaps + MPLS_EXP, // expMaps + DOT1P, // pcpMaps +}; + +/** + * Direction for DSCP/EXP/DOT1P maps. + * INGRESS: codepoint -> traffic class (additive, classification) + * EGRESS: traffic class -> codepoint (rewrite) + */ +enum class QosMapDirection { + INGRESS, + EGRESS, +}; + +/** + * Custom type for QoS map entry configuration. + * + * Parses command line arguments in the format: + * For simple maps (tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg): + * + * Example: tc-to-queue 0 0 + * + * For DSCP/EXP/DOT1P maps (source -> destination): + * dscp tc - maps DSCP to traffic class (additive) + * traffic-class dscp - maps traffic class to DSCP + * mpls-exp traffic-class - maps MPLS EXP to traffic class + * traffic-class mpls-exp - maps traffic class to MPLS EXP + * dot1p traffic-class - maps DOT1P to traffic class + * traffic-class dot1p - maps traffic class to DOT1P + */ +class QosMapConfig : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ QosMapConfig(std::vector v); + + QosMapType getMapType() const { + return mapType_; + } + + int16_t getKey() const { + return key_; + } + + int16_t getValue() const { + return value_; + } + + /** + * For DSCP/EXP/DOT1P maps, returns the traffic class. + */ + int16_t getTrafficClass() const { + return trafficClass_; + } + + /** + * For DSCP/EXP/DOT1P maps, returns the direction (ingress or egress). + */ + QosMapDirection getDirection() const { + return direction_; + } + + /** + * Returns true if this is a DSCP/EXP/DOT1P map type. + */ + bool isListMapType() const; + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_MAP_ENTRY; + + private: + void parseListMapType(const std::vector& v); + + QosMapType mapType_{QosMapType::TC_TO_QUEUE}; + int16_t key_{0}; + int16_t value_{0}; + // For DSCP/EXP/DOT1P maps + int16_t trafficClass_{0}; + QosMapDirection direction_{QosMapDirection::INGRESS}; +}; + +struct CmdConfigQosPolicyMapTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQosPolicy; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_MAP_ENTRY; + using ObjectArgType = QosMapConfig; + using RetType = std::string; +}; + +class CmdConfigQosPolicyMap + : public CmdHandler { + public: + using ObjectArgType = CmdConfigQosPolicyMapTraits::ObjectArgType; + using RetType = CmdConfigQosPolicyMapTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const QosPolicyName& policyName, + const ObjectArgType& config); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.cpp b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.cpp new file mode 100644 index 0000000000000..47b320458034a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.cpp @@ -0,0 +1,22 @@ +/* + * 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/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler< + CmdConfigQosPriorityGroupPolicy, + CmdConfigQosPriorityGroupPolicyTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h new file mode 100644 index 0000000000000..d5177f2c6a576 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h @@ -0,0 +1,89 @@ +/* + * 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 +#include +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +// Custom type for priority group policy name argument +class PriorityGroupPolicyName : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ PriorityGroupPolicyName(std::vector v) { + if (v.empty()) { + throw std::invalid_argument("Priority group policy name is required"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected single priority group policy name, got: " + + folly::join(", ", v)); + } + const auto& name = v[0]; + // Valid policy name: starts with letter, alphanumeric + underscore/hyphen, + // 1-64 chars + static const re2::RE2 kValidPolicyNamePattern( + "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"); + if (!re2::RE2::FullMatch(name, kValidPolicyNamePattern)) { + throw std::invalid_argument( + "Invalid priority group policy name: '" + name + + "'. Name must start with a letter, contain only alphanumeric " + "characters, underscores, or hyphens, and be 1-64 characters long."); + } + data_.push_back(name); + } + + const std::string& getName() const { + return data_[0]; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_POLICY_NAME; +}; + +struct CmdConfigQosPriorityGroupPolicyTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQos; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_POLICY_NAME; + using ObjectArgType = PriorityGroupPolicyName; + using RetType = std::string; +}; + +class CmdConfigQosPriorityGroupPolicy + : public CmdHandler< + CmdConfigQosPriorityGroupPolicy, + CmdConfigQosPriorityGroupPolicyTraits> { + public: + using ObjectArgType = CmdConfigQosPriorityGroupPolicyTraits::ObjectArgType; + using RetType = CmdConfigQosPriorityGroupPolicyTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& /* policyName */) { + throw std::runtime_error( + "Incomplete command. Usage: priority-group-policy group-id " + "[min-limit-bytes ] [headroom-limit-bytes ] " + "[resume-offset-bytes ] [static-limit-bytes ] " + "[scaling-factor ] [buffer-pool-name ]"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp new file mode 100644 index 0000000000000..b0d3400f93e42 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp @@ -0,0 +1,202 @@ +/* + * 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/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/agent/gen-cpp2/switch_config_constants.h" +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" +#include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +namespace { + +std::string getValidScalingFactors() { + std::vector names; + for (auto value : + apache::thrift::TEnumTraits::values) { + names.push_back(apache::thrift::util::enumNameSafe(value)); + } + return folly::join(", ", names); +} + +} // namespace + +PriorityGroupConfig::PriorityGroupConfig(std::vector v) { + // Minimum: + if (v.empty()) { + throw std::invalid_argument( + "Expected: [ ...] where is one of: " + "min-limit-bytes, headroom-limit-bytes, resume-offset-bytes, " + "static-limit-bytes, scaling-factor, buffer-pool-name"); + } + + // Parse the group ID (first argument) + const int16_t maxPgId = cfg::switch_config_constants::PORT_PG_VALUE_MAX(); + groupId_ = folly::to(v[0]); + if (groupId_ < 0 || groupId_ > maxPgId) { + throw std::invalid_argument( + fmt::format( + "Priority group ID must be between 0 and {}, got: {}", + maxPgId, + groupId_)); + } + data_.push_back(v[0]); + + // Parse the remaining key-value pairs + // After the group ID, we need pairs of + if ((v.size() - 1) % 2 != 0) { + throw std::invalid_argument( + "Attribute-value pairs must come in pairs. Got odd number of arguments after group ID."); + } + + for (size_t i = 1; i < v.size(); i += 2) { + attributes_.emplace_back(v[i], v[i + 1]); + data_.push_back(v[i]); + data_.push_back(v[i + 1]); + } +} + +CmdConfigQosPriorityGroupPolicyGroupIdTraits::RetType +CmdConfigQosPriorityGroupPolicyGroupId::queryClient( + const HostInfo& /* hostInfo */, + const PriorityGroupPolicyName& policyName, + const ObjectArgType& config) { + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + // Get or create the portPgConfigs map + if (!switchConfig.portPgConfigs()) { + switchConfig.portPgConfigs() = + std::map>{}; + } + + auto& portPgConfigs = *switchConfig.portPgConfigs(); + int16_t groupIdVal = config.getGroupId(); + + // Get or create the policy entry (list of PortPgConfig) + auto& configList = portPgConfigs[policyName.getName()]; + + // Find the PortPgConfig with the matching group ID, or create a new one + cfg::PortPgConfig* targetPgConfig = nullptr; + for (auto& pgConfig : configList) { + if (*pgConfig.id() == groupIdVal) { + targetPgConfig = &pgConfig; + break; + } + } + + if (targetPgConfig == nullptr) { + // Create a new PortPgConfig with the given group ID + cfg::PortPgConfig newConfig; + newConfig.id() = groupIdVal; + newConfig.name() = fmt::format("pg{}", groupIdVal); + newConfig.minLimitBytes() = 0; + newConfig.bufferPoolName() = ""; + configList.push_back(newConfig); + targetPgConfig = &configList.back(); + } + + // Process each attribute-value pair + static const re2::RE2 kValidPoolNamePattern("^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"); + + for (const auto& [attr, value] : config.getAttributes()) { + if (attr == "min-limit-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "min-limit-bytes must be non-negative, got: " + value); + } + targetPgConfig->minLimitBytes() = bytes; + } else if (attr == "headroom-limit-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "headroom-limit-bytes must be non-negative, got: " + value); + } + targetPgConfig->headroomLimitBytes() = bytes; + } else if (attr == "resume-offset-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "resume-offset-bytes must be non-negative, got: " + value); + } + targetPgConfig->resumeOffsetBytes() = bytes; + } else if (attr == "static-limit-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "static-limit-bytes must be non-negative, got: " + value); + } + targetPgConfig->staticLimitBytes() = bytes; + } else if (attr == "scaling-factor") { + cfg::MMUScalingFactor factor{}; + if (!apache::thrift::TEnumTraits::findValue( + value, &factor)) { + throw std::invalid_argument( + "Invalid scaling-factor: '" + value + + "'. Valid values are: " + getValidScalingFactors()); + } + targetPgConfig->sramScalingFactor() = factor; + } else if (attr == "buffer-pool-name") { + if (!re2::RE2::FullMatch(value, kValidPoolNamePattern)) { + throw std::invalid_argument( + "Invalid buffer pool name: '" + value + + "'. Name must start with a letter, contain only alphanumeric " + "characters, underscores, or hyphens, and be 1-64 characters long."); + } + targetPgConfig->bufferPoolName() = value; + } else { + throw std::invalid_argument( + "Unknown attribute: '" + attr + + "'. Valid attributes are: min-limit-bytes, headroom-limit-bytes, " + "resume-offset-bytes, static-limit-bytes, scaling-factor, buffer-pool-name"); + } + } + + // Save the updated config + session.saveConfig(cli::ConfigActionLevel::AGENT_RESTART); + + return fmt::format( + "Successfully configured priority-group-policy '{}' group-id {}", + policyName.getName(), + groupIdVal); +} + +void CmdConfigQosPriorityGroupPolicyGroupId::printOutput( + const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigQosPriorityGroupPolicyGroupId, + CmdConfigQosPriorityGroupPolicyGroupIdTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h new file mode 100644 index 0000000000000..6a0f461a150cc --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h @@ -0,0 +1,81 @@ +/* + * 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 +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +/** + * Custom type for priority group configuration. + * + * Parses command line arguments in the format: + * [ [ ...]] + * + * For example: + * 2 min-limit-bytes 42 headroom-limit-bytes 2048 + */ +class PriorityGroupConfig : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ PriorityGroupConfig(std::vector v); + + int16_t getGroupId() const { + return groupId_; + } + + const std::vector>& getAttributes() + const { + return attributes_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_ID; + + private: + int16_t groupId_{0}; + std::vector> attributes_; +}; + +struct CmdConfigQosPriorityGroupPolicyGroupIdTraits + : public WriteCommandTraits { + using ParentCmd = CmdConfigQosPriorityGroupPolicy; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_ID; + using ObjectArgType = PriorityGroupConfig; + using RetType = std::string; +}; + +class CmdConfigQosPriorityGroupPolicyGroupId + : public CmdHandler< + CmdConfigQosPriorityGroupPolicyGroupId, + CmdConfigQosPriorityGroupPolicyGroupIdTraits> { + public: + using ObjectArgType = + CmdConfigQosPriorityGroupPolicyGroupIdTraits::ObjectArgType; + using RetType = CmdConfigQosPriorityGroupPolicyGroupIdTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const PriorityGroupPolicyName& policyName, + const ObjectArgType& config); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.cpp b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.cpp new file mode 100644 index 0000000000000..201301fc9b589 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.cpp @@ -0,0 +1,21 @@ +/* + * 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/qos/queuing_policy/CmdConfigQosQueuingPolicy.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h new file mode 100644 index 0000000000000..e9ae6ae53ab90 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h @@ -0,0 +1,86 @@ +/* + * 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 +#include +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +// Custom type for queuing policy name argument with validation +class QueuingPolicyName : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ QueuingPolicyName(std::vector v) { + if (v.empty()) { + throw std::invalid_argument("Queuing policy name is required"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected single queuing policy name, got: " + folly::join(", ", v)); + } + const auto& name = v[0]; + // Valid policy name: starts with letter, alphanumeric + underscore/hyphen, + // 1-64 chars + static const re2::RE2 kValidPolicyNamePattern( + "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"); + if (!re2::RE2::FullMatch(name, kValidPolicyNamePattern)) { + throw std::invalid_argument( + "Invalid queuing policy name: '" + name + + "'. Name must start with a letter, contain only alphanumeric " + "characters, underscores, or hyphens, and be 1-64 characters long."); + } + data_.push_back(name); + } + + const std::string& getName() const { + return data_[0]; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUING_POLICY_NAME; +}; + +struct CmdConfigQosQueuingPolicyTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQos; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUING_POLICY_NAME; + using ObjectArgType = QueuingPolicyName; + using RetType = std::string; +}; + +class CmdConfigQosQueuingPolicy : public CmdHandler< + CmdConfigQosQueuingPolicy, + CmdConfigQosQueuingPolicyTraits> { + public: + using ObjectArgType = CmdConfigQosQueuingPolicyTraits::ObjectArgType; + using RetType = CmdConfigQosQueuingPolicyTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& /* policyName */) { + throw std::runtime_error( + "Incomplete command, please use one of the subcommands: " + "reserved-bytes, scaling-factor, scheduling, weight, " + "shared-bytes, buffer-pool-name"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp new file mode 100644 index 0000000000000..bbcf45c753813 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp @@ -0,0 +1,414 @@ +/* + * 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/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h" +#include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +namespace { + +std::string getValidScalingFactors() { + std::vector names; + for (auto value : + apache::thrift::TEnumTraits::values) { + names.push_back(apache::thrift::util::enumNameSafe(value)); + } + return folly::join(", ", names); +} + +std::string getValidSchedulingTypes() { + std::vector names; + for (auto value : apache::thrift::TEnumTraits::values) { + names.push_back(apache::thrift::util::enumNameSafe(value)); + } + return folly::join(", ", names) + " (or short names: WRR, SP, DRR)"; +} + +std::string getValidStreamTypes() { + std::vector names; + for (auto value : apache::thrift::TEnumTraits::values) { + names.push_back(apache::thrift::util::enumNameSafe(value)); + } + return folly::join(", ", names); +} + +std::string getValidCongestionBehaviors() { + std::vector names; + for (auto value : + apache::thrift::TEnumTraits::values) { + names.push_back(apache::thrift::util::enumNameSafe(value)); + } + return folly::join(", ", names); +} + +// Convert to uppercase and replace dashes with underscores. +// This allows users to type "strict-priority" instead of "STRICT_PRIORITY". +std::string toUpper(const std::string& value) { + std::string result = value; + std::transform( + result.begin(), result.end(), result.begin(), [](unsigned char c) { + return c == '-' ? '_' : std::toupper(c); + }); + return result; +} + +// Parse active-queue-management sub-attributes and update the AQM config. +// The aqmArgs are everything after "active-queue-management" keyword. +// Expected formats: +// congestion-behavior +// detection linear [ ...] +// where linear attrs are: minimum-length, maximum-length, probability +void parseAqmAttributes( + const std::vector& aqmArgs, + cfg::ActiveQueueManagement& aqm) { + if (aqmArgs.empty()) { + throw std::invalid_argument( + "active-queue-management requires sub-attributes: " + "congestion-behavior or detection linear ..."); + } + + const size_t numArgs = aqmArgs.size(); + size_t i = 0; + while (i < numArgs) { + const auto& subAttr = aqmArgs[i]; + + if (subAttr == "congestion-behavior") { + if (i + 1 >= numArgs) { + throw std::invalid_argument( + fmt::format( + "congestion-behavior requires a value. Valid values are: {}", + getValidCongestionBehaviors())); + } + cfg::QueueCongestionBehavior behavior{}; + if (!apache::thrift::TEnumTraits::findValue( + toUpper(aqmArgs[i + 1]), &behavior)) { + throw std::invalid_argument( + fmt::format( + "Invalid congestion-behavior: '{}'. Valid values are: {}", + aqmArgs[i + 1], + getValidCongestionBehaviors())); + } + aqm.behavior() = behavior; + i += 2; + } else if (subAttr == "detection") { + if (i + 1 >= numArgs) { + throw std::invalid_argument( + "detection requires a type. Currently supported: linear"); + } + const auto& detectionType = aqmArgs[i + 1]; + if (toUpper(detectionType) != "LINEAR") { + throw std::invalid_argument( + fmt::format( + "Invalid detection type: '{}'. Currently supported: linear", + detectionType)); + } + i += 2; + + // Parse linear detection attributes + cfg::LinearQueueCongestionDetection linear; + if (aqm.detection().has_value() && + aqm.detection()->linear().has_value()) { + linear = *aqm.detection()->linear(); + } + + while (i < numArgs) { + const auto& linearAttr = aqmArgs[i]; + // Check if this is a new top-level AQM attribute + if (linearAttr == "congestion-behavior") { + break; // Let the outer loop handle it + } + if (i + 1 >= numArgs) { + throw std::invalid_argument( + fmt::format( + "Linear detection attribute '{}' requires a value. " + "Valid attributes are: minimum-length, maximum-length, probability", + linearAttr)); + } + const auto& linearValue = aqmArgs[i + 1]; + + if (linearAttr == "minimum-length") { + int32_t val = folly::to(linearValue); + if (val < 0) { + throw std::invalid_argument( + fmt::format( + "minimum-length must be non-negative, got: {}", + linearValue)); + } + linear.minimumLength() = val; + } else if (linearAttr == "maximum-length") { + int32_t val = folly::to(linearValue); + if (val < 0) { + throw std::invalid_argument( + fmt::format( + "maximum-length must be non-negative, got: {}", + linearValue)); + } + linear.maximumLength() = val; + } else if (linearAttr == "probability") { + int32_t val = folly::to(linearValue); + if (val < 0) { + throw std::invalid_argument( + fmt::format( + "probability must be non-negative, got: {}", linearValue)); + } + linear.probability() = val; + } else { + throw std::invalid_argument( + fmt::format( + "Unknown linear detection attribute: '{}'. " + "Valid attributes are: minimum-length, maximum-length, probability", + linearAttr)); + } + i += 2; + } + + cfg::QueueCongestionDetection detection; + detection.linear() = linear; + aqm.detection() = detection; + } else { + throw std::invalid_argument( + fmt::format( + "Unknown active-queue-management sub-attribute: '{}'. " + "Valid sub-attributes are: congestion-behavior, detection", + subAttr)); + } + } +} + +// Map short names to full enum names for scheduling +std::optional parseScheduling(const std::string& value) { + // Convert to uppercase for case-insensitive matching + std::string upperValue = toUpper(value); + + // Try short names first + static const std::map shortNames = { + {"WRR", cfg::QueueScheduling::WEIGHTED_ROUND_ROBIN}, + {"SP", cfg::QueueScheduling::STRICT_PRIORITY}, + {"DRR", cfg::QueueScheduling::DEFICIT_ROUND_ROBIN}, + }; + + auto it = shortNames.find(upperValue); + if (it != shortNames.end()) { + return it->second; + } + + // Try full enum name + cfg::QueueScheduling scheduling{}; + if (apache::thrift::TEnumTraits::findValue( + upperValue, &scheduling)) { + return scheduling; + } + + return std::nullopt; +} + +} // namespace + +QueueConfig::QueueConfig(std::vector v) { + // Minimum: + if (v.empty()) { + throw std::invalid_argument( + "Expected: [ ...] where is one of: " + "reserved-bytes, shared-bytes, weight, scaling-factor, scheduling, stream-type, " + "buffer-pool-name, active-queue-management"); + } + + // Parse the queue ID (first argument) + // TODO: What's the upper bound, the maximum queue ID seems ASIC dependent? + const int16_t maxQueueId = 128; // Arbitrary but high enough limit + queueId_ = folly::to(v[0]); + if (queueId_ < 0 || queueId_ > maxQueueId) { + throw std::invalid_argument( + fmt::format( + "Queue ID must be between 0 and {}, got: {}", + maxQueueId, + queueId_)); + } + data_.push_back(v[0]); + + // Parse the remaining arguments + // Most attributes are simple key-value pairs, but "active-queue-management" + // has nested sub-attributes that consume all remaining arguments. + for (size_t i = 1; i < v.size();) { + const auto& attr = v[i]; + data_.push_back(attr); + + if (attr == "active-queue-management" || attr == "aqm") { + // Everything after "active-queue-management" is part of the AQM config + std::vector aqmArgs; + for (size_t j = i + 1; j < v.size(); ++j) { + aqmArgs.push_back(v[j]); + data_.push_back(v[j]); + } + aqmAttributes_ = std::move(aqmArgs); + break; // AQM consumes all remaining arguments + } + + // Regular key-value pair + if (i + 1 >= v.size()) { + throw std::invalid_argument( + fmt::format("Attribute '{}' requires a value.", attr)); + } + const auto& value = v[i + 1]; + attributes_.emplace_back(attr, value); + data_.push_back(value); + i += 2; + } +} + +CmdConfigQosQueuingPolicyQueueIdTraits::RetType +CmdConfigQosQueuingPolicyQueueId::queryClient( + const HostInfo& /* hostInfo */, + const QueuingPolicyName& policyName, + const ObjectArgType& config) { + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + // Get or create the portQueueConfigs map + auto& portQueueConfigs = *switchConfig.portQueueConfigs(); + int16_t queueIdVal = config.getQueueId(); + + // Get or create the policy entry (list of PortQueue) + auto& configList = portQueueConfigs[policyName.getName()]; + + // Find the PortQueue with the matching queue ID, or create a new one + cfg::PortQueue* targetConfig = nullptr; + for (auto& queueConfig : configList) { + if (*queueConfig.id() == queueIdVal) { + targetConfig = &queueConfig; + break; + } + } + + if (targetConfig == nullptr) { + // Create a new PortQueue with the given queue ID + cfg::PortQueue newConfig; + newConfig.id() = queueIdVal; + newConfig.scheduling() = cfg::QueueScheduling::WEIGHTED_ROUND_ROBIN; + configList.push_back(newConfig); + targetConfig = &configList.back(); + } + + // Process each attribute-value pair + for (const auto& [attr, value] : config.getAttributes()) { + if (attr == "reserved-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "reserved-bytes must be non-negative, got: " + value); + } + targetConfig->reservedBytes() = bytes; + } else if (attr == "shared-bytes") { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + "shared-bytes must be non-negative, got: " + value); + } + targetConfig->sharedBytes() = bytes; + } else if (attr == "weight") { + int32_t weight = folly::to(value); + if (weight < 0) { + throw std::invalid_argument( + "weight must be non-negative, got: " + value); + } + targetConfig->weight() = weight; + } else if (attr == "scaling-factor") { + cfg::MMUScalingFactor factor{}; + if (!apache::thrift::TEnumTraits::findValue( + toUpper(value), &factor)) { + throw std::invalid_argument( + "Invalid scaling-factor: '" + value + + "'. Valid values are: " + getValidScalingFactors()); + } + targetConfig->scalingFactor() = factor; + } else if (attr == "scheduling") { + auto scheduling = parseScheduling(value); + if (!scheduling) { + throw std::invalid_argument( + "Invalid scheduling: '" + value + + "'. Valid values are: " + getValidSchedulingTypes()); + } + targetConfig->scheduling() = *scheduling; + } else if (attr == "stream-type") { + cfg::StreamType streamType{}; + if (!apache::thrift::TEnumTraits::findValue( + toUpper(value), &streamType)) { + throw std::invalid_argument( + "Invalid stream-type: '" + value + + "'. Valid values are: " + getValidStreamTypes()); + } + targetConfig->streamType() = streamType; + } else if (attr == "buffer-pool-name") { + targetConfig->bufferPoolName() = value; + } else { + throw std::invalid_argument( + "Unknown attribute: '" + attr + + "'. Valid attributes are: reserved-bytes, shared-bytes, weight, " + "scaling-factor, scheduling, stream-type, buffer-pool-name, " + "active-queue-management"); + } + } + + // Process active-queue-management attributes if present + const auto& aqmArgs = config.getAqmAttributes(); + if (!aqmArgs.empty()) { + // Get or create the AQM entry in the aqms list + // For now, we only support a single AQM entry per queue + if (!targetConfig->aqms().has_value() || targetConfig->aqms()->empty()) { + targetConfig->aqms() = std::vector{}; + targetConfig->aqms()->emplace_back(); + } + auto& aqm = targetConfig->aqms()->front(); + parseAqmAttributes(aqmArgs, aqm); + } + + // Save the updated config + session.saveConfig(cli::ConfigActionLevel::AGENT_RESTART); + + return fmt::format( + "Successfully configured queuing-policy '{}' queue-id {}", + policyName.getName(), + queueIdVal); +} + +void CmdConfigQosQueuingPolicyQueueId::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigQosQueuingPolicyQueueId, + CmdConfigQosQueuingPolicyQueueIdTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h new file mode 100644 index 0000000000000..6b26ff3bc09e1 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h @@ -0,0 +1,84 @@ +/* + * 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 +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +/** + * Custom type for queue configuration. + * + * Parses command line arguments in the format: + * [ [ ...]] + * + * For example: + * 0 reserved-bytes 1000 weight 10 scheduling WEIGHTED_ROUND_ROBIN + */ +class QueueConfig : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ QueueConfig(std::vector v); + + int16_t getQueueId() const { + return queueId_; + } + + const std::vector>& getAttributes() + const { + return attributes_; + } + + const std::vector& getAqmAttributes() const { + return aqmAttributes_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUE_ID; + + private: + int16_t queueId_{0}; + std::vector> attributes_; + std::vector aqmAttributes_; +}; + +struct CmdConfigQosQueuingPolicyQueueIdTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQosQueuingPolicy; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUE_ID; + using ObjectArgType = QueueConfig; + using RetType = std::string; +}; + +class CmdConfigQosQueuingPolicyQueueId + : public CmdHandler< + CmdConfigQosQueuingPolicyQueueId, + CmdConfigQosQueuingPolicyQueueIdTraits> { + public: + using ObjectArgType = CmdConfigQosQueuingPolicyQueueIdTraits::ObjectArgType; + using RetType = CmdConfigQosQueuingPolicyQueueIdTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const QueuingPolicyName& policyName, + const ObjectArgType& config); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp b/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp index 2b3b04e07a0ff..3c36b003c4ae7 100644 --- a/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp +++ b/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp @@ -9,6 +9,9 @@ */ #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include "fboss/cli/fboss2/session/ConfigSession.h" namespace facebook::fboss { @@ -21,28 +24,28 @@ CmdConfigRollbackTraits::RetType CmdConfigRollback::queryClient( // Validate arguments if (revisions.size() > 1) { throw std::invalid_argument( - "Too many arguments. Expected 0 or 1 revision specifier."); + "Too many arguments. Expected 0 or 1 commit SHA."); } if (!revisions.empty() && revisions[0] == "current") { throw std::invalid_argument( - "Cannot rollback to 'current'. Please specify a revision number like 'r42'."); + "Cannot rollback to 'current'. Please specify a commit SHA."); } try { - int newRevision; + std::string newCommitSha; if (revisions.empty()) { // No revision specified - rollback to previous revision - newRevision = session.rollback(hostInfo); + newCommitSha = session.rollback(hostInfo); } else { - // Specific revision specified - newRevision = session.rollback(hostInfo, revisions[0]); + // Specific commit SHA specified + newCommitSha = session.rollback(hostInfo, revisions[0]); } - if (newRevision) { - return "Successfully rolled back to r" + std::to_string(newRevision) + - " and config reloaded."; + if (!newCommitSha.empty()) { + return "Successfully rolled back. New commit: " + + newCommitSha.substr(0, 8) + ". Config reloaded."; } else { - return "Failed to create a new revision after rollback."; + return "Failed to create a new commit after rollback."; } } catch (const std::exception& ex) { throw std::runtime_error( @@ -54,4 +57,7 @@ void CmdConfigRollback::printOutput(const RetType& logMsg) { std::cout << logMsg << std::endl; } +// Explicit template instantiation +template void CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.cpp new file mode 100644 index 0000000000000..58c74d92f63cd --- /dev/null +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.cpp @@ -0,0 +1,78 @@ +/* + * 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/session/CmdConfigSessionClear.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace fs = std::filesystem; + +namespace facebook::fboss { + +CmdConfigSessionClearTraits::RetType CmdConfigSessionClear::queryClient( + const HostInfo& /* hostInfo */) { + // Use static path getters to check for session files without calling + // getInstance(), which would create a session if one doesn't exist + std::string sessionConfigPath = ConfigSession::getSessionConfigPathStatic(); + std::string metadataPath = ConfigSession::getSessionMetadataPathStatic(); + + std::error_code ec; + bool removedConfig = false; + bool removedMetadata = false; + + // Remove session config file (~/.fboss2/agent.conf) + if (fs::exists(sessionConfigPath)) { + fs::remove(sessionConfigPath, ec); + if (ec) { + throw std::runtime_error( + fmt::format( + "Failed to remove session config file {}: {}", + sessionConfigPath, + ec.message())); + } + removedConfig = true; + } + + // Remove metadata file (~/.fboss2/cli_metadata.json) + if (fs::exists(metadataPath)) { + ec.clear(); + fs::remove(metadataPath, ec); + if (ec) { + throw std::runtime_error( + fmt::format( + "Failed to remove metadata file {}: {}", + metadataPath, + ec.message())); + } + removedMetadata = true; + } + + if (removedConfig || removedMetadata) { + return "Config session cleared successfully."; + } + return "No config session exists. Nothing to clear."; +} + +void CmdConfigSessionClear::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h new file mode 100644 index 0000000000000..cbd41d4cf25e3 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h @@ -0,0 +1,39 @@ +/* + * 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/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +struct CmdConfigSessionClearTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; // no arg + using RetType = std::string; +}; + +class CmdConfigSessionClear + : public CmdHandler { + public: + using ObjectArgType = CmdConfigSessionClearTraits::ObjectArgType; + using RetType = CmdConfigSessionClearTraits::RetType; + + RetType queryClient(const HostInfo& hostInfo); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp index 14d7300018799..9fa290bda729f 100644 --- a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp @@ -10,6 +10,8 @@ #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include #include #include @@ -76,4 +78,8 @@ void CmdConfigSessionCommit::printOutput(const RetType& logMsg) { std::cout << logMsg << std::endl; } +// Explicit template instantiation +template void +CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp index efb0fb447f8aa..80bcb1cf27d1d 100644 --- a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp @@ -9,46 +9,67 @@ */ #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + #include "fboss/cli/fboss2/session/ConfigSession.h" +#include #include -#include - -namespace fs = std::filesystem; namespace facebook::fboss { namespace { -// Helper function to resolve a revision specifier to a file path -// Note: Revision format validation is done in RevisionList constructor -std::string resolveRevisionPath( +// Helper function to get config content from a revision specifier +// Returns the content and a label for the revision +std::pair getRevisionContent( const std::string& revision, - const std::string& cliConfigDir, - const std::string& systemConfigPath) { - if (revision == "current") { - return systemConfigPath; - } - - // Build the path (revision is already validated to be in "rN" format) - std::string revisionPath = cliConfigDir + "/agent-" + revision + ".conf"; + ConfigSession& session) { + auto& git = session.getGit(); + std::string cliConfigPath = session.getCliConfigPath(); - // Check if the file exists - if (!fs::exists(revisionPath)) { - throw std::invalid_argument( - "Revision " + revision + " does not exist at " + revisionPath); + if (revision == "current") { + // Read the current live config (via the symlink or directly from cli path) + std::string content; + if (!folly::readFile(cliConfigPath.c_str(), content)) { + throw std::runtime_error( + "Failed to read current config from " + cliConfigPath); + } + return {content, "current live config"}; } - return revisionPath; + // Resolve the commit SHA and get the content from Git + std::string resolvedSha = git.resolveRef(revision); + std::string content = git.fileAtRevision(resolvedSha, "cli/agent.conf"); + return {content, revision.substr(0, 8)}; } -// Helper function to execute diff and return the result +// Helper function to execute diff on two strings and return the result std::string executeDiff( - const std::string& path1, - const std::string& path2, + const std::string& content1, + const std::string& content2, const std::string& label1, const std::string& label2) { try { + // Write content to temporary files for diff + std::string tmpFile1 = "/tmp/fboss2_diff_1_XXXXXX"; + std::string tmpFile2 = "/tmp/fboss2_diff_2_XXXXXX"; + + int fd1 = mkstemp(tmpFile1.data()); + int fd2 = mkstemp(tmpFile2.data()); + + if (fd1 < 0 || fd2 < 0) { + throw std::runtime_error("Failed to create temporary files for diff"); + } + + // Write content and close files + folly::writeFull(fd1, content1.data(), content1.size()); + folly::writeFull(fd2, content2.data(), content2.size()); + close(fd1); + close(fd2); + + // Run diff folly::Subprocess proc( std::vector{ "/usr/bin/diff", @@ -57,13 +78,17 @@ std::string executeDiff( label1, "--label", label2, - path1, - path2}, + tmpFile1, + tmpFile2}, folly::Subprocess::Options().pipeStdout().pipeStderr()); auto result = proc.communicate(); int returnCode = proc.wait().exitStatus(); + // Clean up temp files + unlink(tmpFile1.c_str()); + unlink(tmpFile2.c_str()); + // diff returns 0 if files are identical, 1 if different, 2 on error if (returnCode == 0) { return "No differences between " + label1 + " and " + label2 + "."; @@ -89,7 +114,6 @@ CmdConfigSessionDiffTraits::RetType CmdConfigSessionDiff::queryClient( std::string systemConfigPath = session.getSystemConfigPath(); std::string sessionConfigPath = session.getSessionConfigPath(); - std::string cliConfigDir = session.getCliConfigDir(); // Mode 1: No arguments - diff session vs current live config if (revisions.empty()) { @@ -97,9 +121,21 @@ CmdConfigSessionDiffTraits::RetType CmdConfigSessionDiff::queryClient( return "No config session exists. Make a config change first."; } + std::string currentContent; + if (!folly::readFile(systemConfigPath.c_str(), currentContent)) { + throw std::runtime_error( + "Failed to read current config from " + systemConfigPath); + } + + std::string sessionContent; + if (!folly::readFile(sessionConfigPath.c_str(), sessionContent)) { + throw std::runtime_error( + "Failed to read session config from " + sessionConfigPath); + } + return executeDiff( - systemConfigPath, - sessionConfigPath, + currentContent, + sessionContent, "current live config", "session config"); } @@ -110,28 +146,23 @@ CmdConfigSessionDiffTraits::RetType CmdConfigSessionDiff::queryClient( return "No config session exists. Make a config change first."; } - std::string revisionPath = - resolveRevisionPath(revisions[0], cliConfigDir, systemConfigPath); - std::string label = - revisions[0] == "current" ? "current live config" : revisions[0]; + auto [revContent, revLabel] = getRevisionContent(revisions[0], session); - return executeDiff( - revisionPath, sessionConfigPath, label, "session config"); + std::string sessionContent; + if (!folly::readFile(sessionConfigPath.c_str(), sessionContent)) { + throw std::runtime_error( + "Failed to read session config from " + sessionConfigPath); + } + + return executeDiff(revContent, sessionContent, revLabel, "session config"); } // Mode 3: Two arguments - diff between two revisions if (revisions.size() == 2) { - std::string path1 = - resolveRevisionPath(revisions[0], cliConfigDir, systemConfigPath); - std::string path2 = - resolveRevisionPath(revisions[1], cliConfigDir, systemConfigPath); + auto [content1, label1] = getRevisionContent(revisions[0], session); + auto [content2, label2] = getRevisionContent(revisions[1], session); - std::string label1 = - revisions[0] == "current" ? "current live config" : revisions[0]; - std::string label2 = - revisions[1] == "current" ? "current live config" : revisions[1]; - - return executeDiff(path1, path2, label1, label2); + return executeDiff(content1, content2, label1, label2); } // More than 2 arguments is an error @@ -146,4 +177,8 @@ void CmdConfigSessionDiff::printOutput(const RetType& diffOutput) { } } +// Explicit template instantiation +template void +CmdHandler::run(); + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp new file mode 100644 index 0000000000000..461e5a575512e --- /dev/null +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp @@ -0,0 +1,37 @@ +/* + * 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/session/CmdConfigSessionRebase.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +CmdConfigSessionRebaseTraits::RetType CmdConfigSessionRebase::queryClient( + const HostInfo& /* hostInfo */) { + auto& session = ConfigSession::getInstance(); + session.rebase(); // raises a runtime_error if we fail + return "Session successfully rebased onto current HEAD. You can now commit."; +} + +void CmdConfigSessionRebase::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h new file mode 100644 index 0000000000000..994eecb2a31ac --- /dev/null +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h @@ -0,0 +1,39 @@ +/* + * 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/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +struct CmdConfigSessionRebaseTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; // no arg + using RetType = std::string; +}; + +class CmdConfigSessionRebase + : public CmdHandler { + public: + using ObjectArgType = CmdConfigSessionRebaseTraits::ObjectArgType; + using RetType = CmdConfigSessionRebaseTraits::RetType; + + RetType queryClient(const HostInfo& hostInfo); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.cpp b/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.cpp new file mode 100644 index 0000000000000..68fa951793492 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.cpp @@ -0,0 +1,20 @@ +/* + * 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/vlan/CmdConfigVlan.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h b/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h new file mode 100644 index 0000000000000..db61dd4537765 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.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/utils/CmdUtils.h" + +namespace facebook::fboss { + +// Use VlanIdValue from CmdUtils.h +using VlanId = utils::VlanIdValue; + +struct CmdConfigVlanTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID; + using ObjectArgType = VlanId; + using RetType = std::string; +}; + +class CmdConfigVlan : public CmdHandler { + public: + using ObjectArgType = CmdConfigVlanTraits::ObjectArgType; + using RetType = CmdConfigVlanTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& /* vlanId */) { + 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/vlan/port/CmdConfigVlanPort.cpp b/fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.cpp new file mode 100644 index 0000000000000..697942211ecd8 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.cpp @@ -0,0 +1,20 @@ +/* + * 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/vlan/port/CmdConfigVlanPort.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h b/fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h new file mode 100644 index 0000000000000..efdf1bab7b7f3 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.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/vlan/CmdConfigVlan.h" + +namespace facebook::fboss { + +struct CmdConfigVlanPortTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigVlan; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_PORT_LIST; + using ObjectArgType = utils::PortList; + using RetType = std::string; +}; + +class CmdConfigVlanPort + : public CmdHandler { + public: + using ObjectArgType = CmdConfigVlanPortTraits::ObjectArgType; + using RetType = CmdConfigVlanPortTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const VlanId& /* vlanId */, + const ObjectArgType& /* portList */) { + throw std::runtime_error( + "Incomplete command, please use 'taggingMode' subcommand"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp b/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp new file mode 100644 index 0000000000000..7f054ff18f9a5 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp @@ -0,0 +1,117 @@ +/* + * 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/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" + +namespace facebook::fboss { + +CmdConfigVlanPortTaggingModeTraits::RetType +CmdConfigVlanPortTaggingMode::queryClient( + const HostInfo& hostInfo, + const VlanId& vlanIdArg, + const utils::PortList& portList, + const ObjectArgType& taggingMode) { + auto& session = ConfigSession::getInstance(); + auto& config = session.getAgentConfig(); + auto& swConfig = *config.sw(); + int32_t vlanId = vlanIdArg.getVlanId(); + + // Check if VLAN exists in configuration + auto vitr = std::find_if( + swConfig.vlans()->cbegin(), + swConfig.vlans()->cend(), + [vlanId](const auto& vlan) { return *vlan.id() == vlanId; }); + + if (vitr == swConfig.vlans()->cend()) { + throw std::invalid_argument( + fmt::format("VLAN {} does not exist in configuration", vlanId)); + } + + const auto& ports = portList.data(); + if (ports.empty()) { + throw std::invalid_argument("No port name provided"); + } + + // Get port logical IDs from port names + const auto& portMap = session.getPortMap(); + std::vector> portNamesAndIds; + for (const auto& portName : ports) { + auto portLogicalId = portMap.getPortLogicalId(portName); + if (!portLogicalId.has_value()) { + throw std::invalid_argument( + fmt::format("Port '{}' not found in configuration", portName)); + } + portNamesAndIds.emplace_back( + portName, static_cast(*portLogicalId)); + } + + bool emitTags = taggingMode.getEmitTags(); + std::vector updatedPorts; + + // Update VlanPort entries + for (const auto& [portName, portLogicalId] : portNamesAndIds) { + bool found = false; + for (auto& vlanPort : *swConfig.vlanPorts()) { + if (*vlanPort.vlanID() == vlanId && + *vlanPort.logicalPort() == portLogicalId) { + vlanPort.emitTags() = emitTags; + found = true; + updatedPorts.push_back(portName); + break; + } + } + + if (!found) { + throw std::invalid_argument( + fmt::format( + "Port '{}' (ID {}) is not a member of VLAN {}", + portName, + portLogicalId, + vlanId)); + } + } + + // Save the updated config - tagging mode changes are hitless (no agent + // restart) + session.saveConfig(); + + std::string modeStr = emitTags ? "tagged" : "untagged"; + if (updatedPorts.size() == 1) { + return fmt::format( + "Successfully set port {} tagging mode to {} on VLAN {}", + updatedPorts[0], + modeStr, + vlanId); + } else { + return fmt::format( + "Successfully set {} ports tagging mode to {} on VLAN {}", + updatedPorts.size(), + modeStr, + vlanId); + } +} + +void CmdConfigVlanPortTaggingMode::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigVlanPortTaggingMode, + CmdConfigVlanPortTaggingModeTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h b/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h new file mode 100644 index 0000000000000..b60c39ec35375 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h @@ -0,0 +1,83 @@ +/* + * 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 "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" + +namespace facebook::fboss { + +// Custom type for tagging mode argument with validation +class TaggingModeArg : public utils::BaseObjectArgType { + public: + /* implicit */ TaggingModeArg( // NOLINT(google-explicit-constructor) + std::vector v) { + if (v.empty()) { + throw std::invalid_argument( + "Tagging mode is required (tagged or untagged)"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected exactly one tagging mode (tagged or untagged)"); + } + + std::string mode = v[0]; + folly::toLowerAscii(mode); + if (mode == "tagged") { + emitTags_ = true; + } else if (mode == "untagged") { + emitTags_ = false; + } else { + throw std::invalid_argument( + "Invalid tagging mode '" + v[0] + + "'. Expected 'tagged' or 'untagged'"); + } + data_.push_back(v[0]); + } + + bool getEmitTags() const { + return emitTags_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_PORT_AND_TAGGING_MODE; + + private: + bool emitTags_ = false; +}; + +struct CmdConfigVlanPortTaggingModeTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigVlanPort; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_PORT_AND_TAGGING_MODE; + using ObjectArgType = TaggingModeArg; + using RetType = std::string; +}; + +class CmdConfigVlanPortTaggingMode : public CmdHandler< + CmdConfigVlanPortTaggingMode, + CmdConfigVlanPortTaggingModeTraits> { + public: + using ObjectArgType = CmdConfigVlanPortTaggingModeTraits::ObjectArgType; + using RetType = CmdConfigVlanPortTaggingModeTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const VlanId& vlanId, + const utils::PortList& portList, + const ObjectArgType& taggingMode); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.cpp b/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.cpp new file mode 100644 index 0000000000000..41336f5dbb5c9 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.cpp @@ -0,0 +1,21 @@ +/* + * 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/vlan/static_mac/CmdConfigVlanStaticMac.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +namespace facebook::fboss { + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h b/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h new file mode 100644 index 0000000000000..4c373272bea6d --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h @@ -0,0 +1,42 @@ +/* + * 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/vlan/CmdConfigVlan.h" + +namespace facebook::fboss { + +struct CmdConfigVlanStaticMacTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigVlan; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdConfigVlanStaticMac + : public CmdHandler { + public: + using ObjectArgType = CmdConfigVlanStaticMacTraits::ObjectArgType; + using RetType = CmdConfigVlanStaticMacTraits::RetType; + + RetType queryClient( + const HostInfo& /* hostInfo */, + const VlanId& /* vlanId */) { + throw std::runtime_error( + "Incomplete command, please use 'add' or 'delete' subcommand"); + } + + void printOutput(const RetType& /* model */) {} +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp b/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp new file mode 100644 index 0000000000000..3899e663a0ff9 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp @@ -0,0 +1,94 @@ +/* + * 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/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" + +namespace facebook::fboss { + +CmdConfigVlanStaticMacAddTraits::RetType CmdConfigVlanStaticMacAdd::queryClient( + const HostInfo& hostInfo, + const VlanId& vlanIdArg, + const ObjectArgType& macAndPort) { + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + int32_t vlanId = vlanIdArg.getVlanId(); + + // Check if VLAN exists in configuration + auto vitr = std::find_if( + swConfig.vlans()->cbegin(), + swConfig.vlans()->cend(), + [vlanId](const auto& vlan) { return *vlan.id() == vlanId; }); + + if (vitr == swConfig.vlans()->cend()) { + throw std::invalid_argument( + fmt::format("VLAN {} does not exist in configuration", vlanId)); + } + + // Get port logical ID from port name + const auto& portMap = ConfigSession::getInstance().getPortMap(); + auto portLogicalId = portMap.getPortLogicalId(macAndPort.getPortName()); + if (!portLogicalId.has_value()) { + throw std::invalid_argument( + fmt::format( + "Port '{}' not found in configuration", macAndPort.getPortName())); + } + + const std::string& macAddress = macAndPort.getMacAddress(); + + // Check if entry already exists - if so, return success (idempotent) + if (swConfig.staticMacAddrs().has_value()) { + for (const auto& entry : *swConfig.staticMacAddrs()) { + if (*entry.vlanID() == vlanId && *entry.macAddress() == macAddress) { + return fmt::format( + "Static MAC entry for MAC {} on VLAN {} already exists", + macAddress, + vlanId); + } + } + } + + // Create and add the new static MAC entry + cfg::StaticMacEntry newEntry; + newEntry.vlanID() = vlanId; + newEntry.macAddress() = macAddress; + newEntry.egressLogicalPortID() = static_cast(*portLogicalId); + + if (!swConfig.staticMacAddrs().has_value()) { + swConfig.staticMacAddrs() = std::vector(); + } + swConfig.staticMacAddrs()->push_back(newEntry); + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + return fmt::format( + "Successfully added static MAC entry: MAC {} -> VLAN {}, Port {} (ID {})", + macAddress, + vlanId, + macAndPort.getPortName(), + static_cast(*portLogicalId)); +} + +void CmdConfigVlanStaticMacAdd::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void +CmdHandler::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h b/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h new file mode 100644 index 0000000000000..1b0c899578f9a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h @@ -0,0 +1,80 @@ +/* + * 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 "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h" + +namespace facebook::fboss { + +// Custom type for MAC address and port ID arguments +class MacAndPortArg : public utils::BaseObjectArgType { + public: + /* implicit */ MacAndPortArg( // NOLINT(google-explicit-constructor) + std::vector v) { + if (v.size() != 2) { + throw std::invalid_argument( + "Expected , got " + + std::to_string(v.size()) + " arguments"); + } + + // Validate MAC address format + auto macAddr = folly::MacAddress::tryFromString(v[0]); + if (!macAddr.hasValue()) { + throw std::invalid_argument( + "Invalid MAC address format: " + v[0] + + ". Expected format: XX:XX:XX:XX:XX:XX"); + } + macAddress_ = v[0]; + portName_ = v[1]; + } + + const std::string& getMacAddress() const { + return macAddress_; + } + + const std::string& getPortName() const { + return portName_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MAC_AND_PORT; + + private: + std::string macAddress_; + std::string portName_; +}; + +struct CmdConfigVlanStaticMacAddTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigVlanStaticMac; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_MAC_AND_PORT; + using ObjectArgType = MacAndPortArg; + using RetType = std::string; +}; + +class CmdConfigVlanStaticMacAdd : public CmdHandler< + CmdConfigVlanStaticMacAdd, + CmdConfigVlanStaticMacAddTraits> { + public: + using ObjectArgType = CmdConfigVlanStaticMacAddTraits::ObjectArgType; + using RetType = CmdConfigVlanStaticMacAddTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const VlanId& vlanId, + const ObjectArgType& macAndPort); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp b/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp new file mode 100644 index 0000000000000..2ccd94cc5c2ef --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp @@ -0,0 +1,89 @@ +/* + * 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/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h" + +#include "fboss/cli/fboss2/CmdHandler.cpp" + +#include +#include +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +CmdConfigVlanStaticMacDeleteTraits::RetType +CmdConfigVlanStaticMacDelete::queryClient( + const HostInfo& hostInfo, + const VlanId& vlanIdArg, + const ObjectArgType& macAddressArg) { + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + int32_t vlanId = vlanIdArg.getVlanId(); + + // Check if VLAN exists in configuration + auto vitr = std::find_if( + swConfig.vlans()->cbegin(), + swConfig.vlans()->cend(), + [vlanId](const auto& vlan) { return *vlan.id() == vlanId; }); + + if (vitr == swConfig.vlans()->cend()) { + throw std::invalid_argument( + fmt::format("VLAN {} does not exist in configuration", vlanId)); + } + + const std::string& macAddress = macAddressArg.getMacAddress(); + + // Check if staticMacAddrs exists and find the entry to delete + // If no entries exist or entry not found, return success (idempotent) + if (!swConfig.staticMacAddrs().has_value() || + swConfig.staticMacAddrs()->empty()) { + return fmt::format( + "Static MAC entry for MAC {} on VLAN {} does not exist (no entries configured)", + macAddress, + vlanId); + } + + auto& staticMacs = *swConfig.staticMacAddrs(); + auto it = std::find_if( + staticMacs.begin(), + staticMacs.end(), + [vlanId, &macAddress](const auto& entry) { + return *entry.vlanID() == vlanId && *entry.macAddress() == macAddress; + }); + + if (it == staticMacs.end()) { + return fmt::format( + "Static MAC entry for MAC {} on VLAN {} does not exist", + macAddress, + vlanId); + } + + // Remove the entry + staticMacs.erase(it); + + // Save the updated config + ConfigSession::getInstance().saveConfig(); + + return fmt::format( + "Successfully deleted static MAC entry: MAC {} from VLAN {}", + macAddress, + vlanId); +} + +void CmdConfigVlanStaticMacDelete::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +// Explicit template instantiation +template void CmdHandler< + CmdConfigVlanStaticMacDelete, + CmdConfigVlanStaticMacDeleteTraits>::run(); + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h b/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h new file mode 100644 index 0000000000000..07a11469a0aca --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h @@ -0,0 +1,74 @@ +/* + * 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 "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h" + +namespace facebook::fboss { + +// Custom type for MAC address argument only (no port needed for delete) +class MacAddressArg : public utils::BaseObjectArgType { + public: + /* implicit */ MacAddressArg( // NOLINT(google-explicit-constructor) + std::vector v) { + if (v.size() != 1) { + throw std::invalid_argument( + "Expected , got " + std::to_string(v.size()) + + " arguments"); + } + + // Validate MAC address format + auto macAddr = folly::MacAddress::tryFromString(v[0]); + if (!macAddr.hasValue()) { + throw std::invalid_argument( + "Invalid MAC address format: " + v[0] + + ". Expected format: XX:XX:XX:XX:XX:XX"); + } + macAddress_ = v[0]; + } + + const std::string& getMacAddress() const { + return macAddress_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; + + private: + std::string macAddress_; +}; + +struct CmdConfigVlanStaticMacDeleteTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigVlanStaticMac; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; + using ObjectArgType = MacAddressArg; + using RetType = std::string; +}; + +class CmdConfigVlanStaticMacDelete : public CmdHandler< + CmdConfigVlanStaticMacDelete, + CmdConfigVlanStaticMacDeleteTraits> { + public: + using ObjectArgType = CmdConfigVlanStaticMacDeleteTraits::ObjectArgType; + using RetType = CmdConfigVlanStaticMacDeleteTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const VlanId& vlanId, + const ObjectArgType& macAddress); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.cpp b/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.cpp new file mode 100644 index 0000000000000..80fdbd033ab5b --- /dev/null +++ b/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.cpp @@ -0,0 +1,41 @@ +/* + * 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 +#include +#include + +#include "fboss/agent/if/gen-cpp2/FbossCtrlAsyncClient.h" +#include "fboss/cli/fboss2/utils/CmdClientUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +#include "CmdShowRunningConfig.h" + +namespace facebook::fboss { + +CmdShowRunningConfigTraits::RetType CmdShowRunningConfig::queryClient( + const HostInfo& hostInfo) { + auto client = + utils::createClient>(hostInfo); + std::string configStr; + client->sync_getRunningConfig(configStr); + + // Parse and pretty-print the JSON + // The config is already a valid JSON string, we just need to format it + return folly::toPrettyJson(folly::parseJson(configStr)); +} + +void CmdShowRunningConfig::printOutput( + const RetType& config, + std::ostream& out) { + out << config << std::endl; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h b/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h new file mode 100644 index 0000000000000..b5b3daf486e0e --- /dev/null +++ b/fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h @@ -0,0 +1,41 @@ +/* + * 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 +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" + +namespace facebook::fboss { + +struct CmdShowRunningConfigTraits : public ReadCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdShowRunningConfig + : public CmdHandler { + public: + using ObjectArgType = CmdShowRunningConfigTraits::ObjectArgType; + using RetType = CmdShowRunningConfigTraits::RetType; + + RetType queryClient(const HostInfo& hostInfo); + + void printOutput(const RetType& config, std::ostream& out = std::cout); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index e3eeb347aeb18..84790d5f9c7f8 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -10,31 +10,43 @@ #include "fboss/cli/fboss2/session/ConfigSession.h" -#include -#include #include #include -#include #include #include +#include #include #include #include -#include #include #include #include #include #include #include +#include +#include +#include #include -#include +#include +#include +#include +#include #include +#include +#include #include #include +#include #include "fboss/agent/AgentDirectoryUtil.h" +#include "fboss/agent/gen-cpp2/agent_config_types.h" +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/agent/if/gen-cpp2/FbossCtrl.h" +#include "fboss/agent/if/gen-cpp2/FbossCtrlAsyncClient.h" #include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" -#include "fboss/cli/fboss2/utils/CmdClientUtils.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/utils/CmdClientUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" #include "fboss/cli/fboss2/utils/PortMap.h" namespace fs = std::filesystem; @@ -60,8 +72,9 @@ void atomicSymlinkUpdate( // Generate a unique temporary path in the same directory as the target // symlink, we'll then atomically rename it to the final symlink name. - std::string tmpLinkName = - fmt::format("fboss2_tmp_{}", boost::filesystem::unique_path().string()); + auto now = std::chrono::system_clock::now().time_since_epoch(); + auto ns = std::chrono::duration_cast(now).count(); + std::string tmpLinkName = fmt::format("fboss2_tmp_{}_{}", getpid(), ns); fs::path tempSymlinkPath = symlinkFsPath.parent_path() / tmpLinkName; // Create new symlink with temporary name @@ -88,53 +101,6 @@ void atomicSymlinkUpdate( } } -/* - * Atomically create the next revision file for a given prefix. - * This function finds the next available revision number (starting from 1) - * and atomically creates a file with that revision number using O_CREAT|O_EXCL. - * This ensures that concurrent commits will get different revision numbers. - * - * @param pathPrefix The path prefix (e.g., "/etc/coop/cli/agent") - * @return A pair containing the path to the newly created revision file - * (e.g., "/etc/coop/cli/agent-r1.conf") and the revision number - * @throws std::runtime_error if unable to create a revision file after many - * attempts - */ -std::pair createNextRevisionFile( - const std::string& pathPrefix) { - // Try up to 100000 revision numbers to handle concurrent commits - // In practice, we should find one quickly - for (int revision = 1; revision <= 100000; ++revision) { - std::string revisionPath = fmt::format("{}-r{}.conf", pathPrefix, revision); - - // Try to atomically create the file with O_CREAT | O_EXCL - // This will fail if the file already exists, ensuring atomicity - int fd = open(revisionPath.c_str(), O_CREAT | O_EXCL | O_WRONLY, 0644); - - if (fd >= 0) { - // Successfully created the file - close it and return the path - close(fd); - return {revisionPath, revision}; - } - - // If errno is EEXIST, the file already exists - try the next revision - if (errno != EEXIST) { - // Some other error occurred - throw std::runtime_error( - fmt::format( - "Failed to create revision file {}: {}", - revisionPath, - folly::errnoStr(errno))); - } - - // File exists, try next revision number - } - - throw std::runtime_error( - "Failed to create revision file after 100000 attempts. " - "This likely indicates a problem with the filesystem or too many revisions."); -} - std::string getUsername() { const char* user = std::getenv("USER"); if (user != nullptr && !std::string(user).empty()) { @@ -173,51 +139,169 @@ void ensureDirectoryExists(const std::string& dirPath) { } /* - * Get the current revision number by reading the symlink target. - * Returns -1 if unable to determine the current revision. + * Read the command line from /proc/self/cmdline, skipping argv[0]. + * Returns the command arguments as a space-separated string, + * e.g., "config interface eth1/1/1 mtu 9000" */ -int getCurrentRevisionNumber(const std::string& systemConfigPath) { - std::error_code ec; +std::string readCommandLineFromProc() { + std::ifstream file("/proc/self/cmdline"); + if (!file) { + throw std::runtime_error( + fmt::format( + "Failed to open /proc/self/cmdline: {}", folly::errnoStr(errno))); + } - if (!fs::is_symlink(systemConfigPath, ec)) { - return -1; + std::vector args; + std::string arg; + bool first = true; + while (std::getline(file, arg, '\0')) { + if (first) { + // Skip argv[0] (program name) + first = false; + continue; + } + if (!arg.empty()) { + args.push_back(arg); + } } + return folly::join(" ", args); +} - std::string target = fs::read_symlink(systemConfigPath, ec); - if (ec) { - return -1; +// Maximum number of conflicts to report before truncating with "and more" +constexpr size_t kMaxConflicts = 10; + +// Add a conflict to the list, appending "and more" when we hit the limit. +void addConflict(std::vector& conflicts, std::string conflict) { + conflicts.push_back(std::move(conflict)); + if (conflicts.size() == kMaxConflicts - 1) { + conflicts.emplace_back("and more"); + } +} + +/* + * Perform a recursive 3-way merge of JSON objects. + * + * @param base The original/base version + * @param head The version that was changed by someone else (current HEAD) + * @param session The version with the user's changes + * @param path Current path in the JSON tree (for conflict reporting) + * @param conflicts Vector to collect conflict paths (capped at kMaxConflicts) + * @return The merged JSON, preferring session changes over head when safe + * (the return value must be ignored when "conflicts" is not empty) + */ +folly::dynamic threeWayMerge( + const folly::dynamic& base, + const folly::dynamic& head, + const folly::dynamic& session, + const std::string& path, + std::vector& conflicts) { + // If we've already hit max conflicts, stop recursing + if (conflicts.size() >= kMaxConflicts) { + return session; + } + + // Note: folly::dynamic::operator== does deep comparison which is O(n) for the + // entire subtree. We compare subtrees O(n) times leading to O(n²) complexity. + // While suboptimal, benchmarking showed ~11ms for a 41k line config file, + // which is acceptable for CLI usage, given how simple the implementation is. + + // If session equals base, user didn't change this - use head's version + if (session == base) { + return head; + } + + // If head equals base, other user didn't change this - use session's version + if (head == base) { + return session; } - return ConfigSession::extractRevisionNumber(target); + // Both changed - if they made the same change, that's fine + if (head == session) { + return session; + } + + // Both changed differently - need to handle based on type + if (base.isObject() && head.isObject() && session.isObject()) { + // Recursively merge objects + folly::dynamic result = folly::dynamic::object; + + // Collect all keys from all three versions + std::set allKeys; + for (const auto& kv : base.items()) { + allKeys.insert(kv.first.asString()); + } + for (const auto& kv : head.items()) { + allKeys.insert(kv.first.asString()); + } + for (const auto& kv : session.items()) { + allKeys.insert(kv.first.asString()); + } + + for (const auto& key : allKeys) { + std::string childPath = + path.empty() ? key : fmt::format("{}.{}", path, key); + + // Get values from each version (null if not present) + folly::dynamic baseVal = base.getDefault(key, nullptr); + folly::dynamic headVal = head.getDefault(key, nullptr); + folly::dynamic sessionVal = session.getDefault(key, nullptr); + + folly::dynamic mergedVal = + threeWayMerge(baseVal, headVal, sessionVal, childPath, conflicts); + + // Don't include null values (represents deletion) + if (!mergedVal.isNull()) { + result[key] = std::move(mergedVal); + } + } + return result; + } + + if (base.isArray() && head.isArray() && session.isArray()) { + // For arrays, we can try element-by-element merge if sizes match + if (base.size() == head.size() && base.size() == session.size()) { + folly::dynamic result = folly::dynamic::array; + for (size_t i = 0; i < base.size(); ++i) { + std::string childPath = fmt::format("{}[{}]", path, i); + result.push_back( + threeWayMerge(base[i], head[i], session[i], childPath, conflicts)); + } + return result; + } + // Array sizes differ - this is a conflict + addConflict(conflicts, path + " (array size mismatch)"); + return session; // Return session's version, but report conflict + } + + // Scalar values that both changed differently - conflict + addConflict(conflicts, path); + return session; // Return session's version, but report conflict } } // anonymous namespace -ConfigSession::ConfigSession() { - username_ = getUsername(); - std::string homeDir = getHomeDirectory(); - +ConfigSession::ConfigSession() + : sessionConfigDir_(getHomeDirectory() + "/.fboss2"), + username_(getUsername()) { // Use AgentDirectoryUtil to get the config directory path // getConfigDirectory() returns /etc/coop/agent, so we get the parent to get // /etc/coop AgentDirectoryUtil dirUtil; - fs::path configDir = fs::path(dirUtil.getConfigDirectory()).parent_path(); - - sessionConfigPath_ = homeDir + "/.fboss2/agent.conf"; - systemConfigPath_ = (configDir / "agent.conf").string(); - cliConfigDir_ = (configDir / "cli").string(); + std::string coopDir = + fs::path(dirUtil.getConfigDirectory()).parent_path().string(); + systemConfigDir_ = coopDir; + git_ = std::make_unique(coopDir); initializeSession(); } ConfigSession::ConfigSession( - const std::string& sessionConfigPath, - const std::string& systemConfigPath, - const std::string& cliConfigDir) - : sessionConfigPath_(sessionConfigPath), - systemConfigPath_(systemConfigPath), - cliConfigDir_(cliConfigDir) { - username_ = getUsername(); + std::string sessionConfigDir, + std::string systemConfigDir) + : sessionConfigDir_(std::move(sessionConfigDir)), + systemConfigDir_(std::move(systemConfigDir)), + username_(getUsername()), + git_(std::make_unique(systemConfigDir_)) { initializeSession(); } @@ -240,20 +324,37 @@ void ConfigSession::setInstance(std::unique_ptr newInstance) { getInstancePtr() = std::move(newInstance); } +// Static path getters - can be called without creating a session instance +std::string ConfigSession::getSessionDir() { + return getHomeDirectory() + "/.fboss2"; +} + +std::string ConfigSession::getSessionConfigPathStatic() { + return getSessionDir() + "/agent.conf"; +} + +std::string ConfigSession::getSessionMetadataPathStatic() { + return getSessionDir() + "/cli_metadata.json"; +} + std::string ConfigSession::getSessionConfigPath() const { - return sessionConfigPath_; + return sessionConfigDir_ + "/agent.conf"; } std::string ConfigSession::getSystemConfigPath() const { - return systemConfigPath_; + return systemConfigDir_ + "/agent.conf"; } std::string ConfigSession::getCliConfigDir() const { - return cliConfigDir_; + return systemConfigDir_ + "/cli"; +} + +std::string ConfigSession::getCliConfigPath() const { + return systemConfigDir_ + "/cli/agent.conf"; } bool ConfigSession::sessionExists() const { - return fs::exists(sessionConfigPath_); + return fs::exists(getSessionConfigPath()); } cfg::AgentConfig& ConfigSession::getAgentConfig() { @@ -298,7 +399,7 @@ void ConfigSession::saveConfig( // (like clientIdToAdminDistance) by converting them to strings. // If we use facebook::thrift::to_dynamic() directly, the integer keys // are preserved as integers in the folly::dynamic object, which causes - // folly::toPrettyJson() to fail because JSON objects require string keys. + // folly::toPrettyJson() to fail because JSON objects requires string keys. std::string json = apache::thrift::SimpleJSONSerializer::serialize( agentConfig_); @@ -309,35 +410,28 @@ void ConfigSession::saveConfig( // is flushed to disk before the atomic rename, preventing readers from // seeing partial/corrupted data. folly::writeFileAtomic( - sessionConfigPath_, prettyJson, 0644, folly::SyncType::WITH_SYNC); + getSessionConfigPath(), prettyJson, 0644, folly::SyncType::WITH_SYNC); // Update the required action metadata for this service updateRequiredAction(service, actionLevel); } -int ConfigSession::extractRevisionNumber(const std::string& filenameOrPath) { - // Extract just the filename if a full path was provided - std::string filename = filenameOrPath; - size_t lastSlash = filenameOrPath.rfind('/'); - if (lastSlash != std::string::npos) { - filename = filenameOrPath.substr(lastSlash + 1); - } - - // Pattern: agent-rN.conf where N is a positive integer - // Using RE2 instead of std::regex to avoid stack overflow issues (GCC bug) - static const re2::RE2 pattern(R"(^agent-r(\d+)\.conf$)"); - int revision = -1; +Git& ConfigSession::getGit() { + return *git_; +} - if (re2::RE2::FullMatch(filename, pattern, &revision)) { - return revision; - } - return -1; +const Git& ConfigSession::getGit() const { + return *git_; } std::string ConfigSession::getMetadataPath() const { // Store metadata in the same directory as session config - fs::path sessionPath(sessionConfigPath_); - return (sessionPath.parent_path() / "conf_metadata.json").string(); + return sessionConfigDir_ + "/cli_metadata.json"; +} + +std::string ConfigSession::getSystemMetadataPath() const { + // Store system metadata in the CLI config directory (Git-versioned) + return getCliConfigDir() + "/cli_metadata.json"; } std::string ConfigSession::getServiceName(cli::ServiceType service) { @@ -349,7 +443,7 @@ std::string ConfigSession::getServiceName(cli::ServiceType service) { throw std::runtime_error("Unknown service type"); } -void ConfigSession::loadActionLevel() { +void ConfigSession::loadMetadata() { std::string metadataPath = getMetadataPath(); // Note: We don't initialize requiredActions_ here since getRequiredAction() // returns HITLESS by default for agents not in the map, and @@ -376,19 +470,23 @@ void ConfigSession::loadActionLevel() { facebook::thrift::dynamic_format::PORTABLE, facebook::thrift::format_adherence::LENIENT); requiredActions_ = *metadata.action(); + commands_ = *metadata.commands(); + base_ = *metadata.base(); } catch (const std::exception& ex) { // If JSON parsing fails, keep defaults LOG(WARNING) << "Failed to parse metadata file: " << ex.what(); } } -void ConfigSession::saveActionLevel() { +void ConfigSession::saveMetadata() { std::string metadataPath = getMetadataPath(); // Build Thrift metadata struct and serialize to JSON with symbolic enum names // Using PORTABLE format for human-readable enum names instead of integers cli::ConfigSessionMetadata metadata; metadata.action() = requiredActions_; + metadata.commands() = commands_; + metadata.base() = base_; folly::dynamic json = facebook::thrift::to_dynamic( metadata, facebook::thrift::dynamic_format::PORTABLE); @@ -565,10 +663,17 @@ void ConfigSession::applyServiceActions( } void ConfigSession::loadConfig() { + // If session file doesn't exist (e.g., after a commit), re-initialize + // the session by copying from system config. + if (!sessionExists()) { + initializeSession(); + } + std::string configJson; - if (!folly::readFile(sessionConfigPath_.c_str(), configJson)) { + std::string sessionConfigPath = getSessionConfigPath(); + if (!folly::readFile(sessionConfigPath.c_str(), configJson)) { throw std::runtime_error( - fmt::format("Failed to read config file: {}", sessionConfigPath_)); + fmt::format("Failed to read config file: {}", sessionConfigPath)); } apache::thrift::SimpleJSONSerializer::deserialize( @@ -584,50 +689,61 @@ void ConfigSession::loadConfig() { } void ConfigSession::initializeSession() { + initializeGit(); if (!sessionExists()) { - // Ensure the parent directory of the session config exists - fs::path sessionPath(sessionConfigPath_); - ensureDirectoryExists(sessionPath.parent_path().string()); + // Starting a new session - reset all state to ensure we don't carry over + // stale data from a previous session (e.g., if the singleton persisted + // in memory but the session files were deleted). + commands_.clear(); + requiredActions_.clear(); + configLoaded_ = false; + + // Ensure the session config directory exists + ensureDirectoryExists(sessionConfigDir_); copySystemConfigToSession(); + // Capture the current git HEAD as the base for this session. + // This is used to detect if someone else committed changes while this + // session was in progress. + base_ = git_->getHead(); + // Create initial metadata file for new sessions + saveMetadata(); + } else { + // Load metadata from disk (survives across CLI invocations) + loadMetadata(); } - // Load the action level from disk (survives across CLI invocations) - loadActionLevel(); } -void ConfigSession::copySystemConfigToSession() { - // Resolve symlink if system config is a symlink - std::string sourceConfig = systemConfigPath_; - std::error_code ec; +void ConfigSession::initializeGit() { + // Initialize Git repository if it doesn't exist + if (!git_->isRepository()) { + ensureDirectoryExists(getCliConfigDir()); + git_->init(); + } - if (LIKELY(fs::is_symlink(systemConfigPath_, ec))) { - sourceConfig = fs::read_symlink(systemConfigPath_, ec).string(); - if (ec) { - throw std::runtime_error( - fmt::format( - "Failed to read symlink {}: {}", - systemConfigPath_, - ec.message())); - } - // If the symlink is relative, make it absolute relative to the system - // config directory - if (!fs::path(sourceConfig).is_absolute()) { - fs::path systemConfigDir = fs::path(systemConfigPath_).parent_path(); - sourceConfig = (systemConfigDir / sourceConfig).string(); - } + // If the repository has no commits but the config file exists, + // create an initial commit to track the existing configuration + std::string cliConfigPath = getCliConfigPath(); + if (!git_->hasCommits() && fs::exists(cliConfigPath)) { + std::string systemConfigPath = getSystemConfigPath(); + git_->commit( + {cliConfigPath, systemConfigPath}, "Initial commit", username_, ""); } +} - // Read source config and write atomically to session config +void ConfigSession::copySystemConfigToSession() const { + // Read system config and write atomically to session config // This ensures that readers never see a partially written file - they either // see the old file or the new file, never a mix. // WITH_SYNC ensures data is flushed to disk before the atomic rename. std::string configData; - if (!folly::readFile(sourceConfig.c_str(), configData)) { + std::string systemConfigPath = getSystemConfigPath(); + if (!folly::readFile(systemConfigPath.c_str(), configData)) { throw std::runtime_error( - fmt::format("Failed to read config from {}", sourceConfig)); + fmt::format("Failed to read config from {}", systemConfigPath)); } folly::writeFileAtomic( - sessionConfigPath_, configData, 0644, folly::SyncType::WITH_SYNC); + getSessionConfigPath(), configData, 0644, folly::SyncType::WITH_SYNC); } ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { @@ -636,48 +752,73 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { "No config session exists. Make a config change first."); } - ensureDirectoryExists(cliConfigDir_); - - // Atomically create the next revision file - // This ensures concurrent commits get different revision numbers - auto [targetConfigPath, revision] = - createNextRevisionFile(fmt::format("{}/agent", cliConfigDir_)); - std::error_code ec; - - // Read the old symlink target for rollback if needed - std::string oldSymlinkTarget; - if (!fs::is_symlink(systemConfigPath_)) { + // Check if someone else committed changes while this session was in progress + std::string currentHead = git_->getHead(); + if (!base_.empty() && currentHead != base_) { throw std::runtime_error( fmt::format( - "{} is not a symlink. Expected it to be a symlink.", - systemConfigPath_)); + "Cannot commit: the system configuration has changed since this " + "session was started. Your session was based on commit {}, but the " + "current HEAD is {}. Run 'config session rebase' to rebase your " + "changes onto the current configuration, or discard your session " + "and start over.", + base_.substr(0, 7), + currentHead.substr(0, 7))); } - oldSymlinkTarget = fs::read_symlink(systemConfigPath_, ec); - if (ec) { + + std::string cliConfigDir = getCliConfigDir(); + std::string cliConfigPath = getCliConfigPath(); + std::string sessionConfigPath = getSessionConfigPath(); + std::string systemConfigPath = getSystemConfigPath(); + + ensureDirectoryExists(cliConfigDir); + + // Read the session config content + std::string sessionConfigData; + if (!folly::readFile(sessionConfigPath.c_str(), sessionConfigData)) { throw std::runtime_error( fmt::format( - "Failed to read symlink {}: {}", systemConfigPath_, ec.message())); + "Failed to read session config from {}", sessionConfigPath)); + } + + // Read the old config for rollback if needed + std::string oldConfigData; + if (fs::exists(cliConfigPath)) { + if (!folly::readFile(cliConfigPath.c_str(), oldConfigData)) { + throw std::runtime_error( + fmt::format("Failed to read CLI config from {}", cliConfigPath)); + } } - // Copy session config to the atomically-created revision file - // Overwrite the empty file that was created by createNextRevisionFile + // Copy the metadata file alongside the config revision + // This is required for rollback functionality + std::string metadataPath = getMetadataPath(); + std::string targetMetadataPath = + fmt::format("{}/cli_metadata.json", cliConfigDir); + std::error_code ec; fs::copy_file( - sessionConfigPath_, - targetConfigPath, + metadataPath, + targetMetadataPath, fs::copy_options::overwrite_existing, ec); if (ec) { - // Clean up the revision file we created - fs::remove(targetConfigPath); + if (!oldConfigData.empty()) { + folly::writeFileAtomic( + cliConfigPath, oldConfigData, 0644, folly::SyncType::WITH_SYNC); + } throw std::runtime_error( fmt::format( - "Failed to copy session config to {}: {}", - targetConfigPath, + "Failed to copy metadata to {}: {}", + targetMetadataPath, ec.message())); } - // Atomically update the symlink to point to the new config - atomicSymlinkUpdate(systemConfigPath_, targetConfigPath); + // Atomically write the session config to the CLI config path + folly::writeFileAtomic( + cliConfigPath, sessionConfigData, 0644, folly::SyncType::WITH_SYNC); + + // Ensure the system config symlink points to the CLI config + atomicSymlinkUpdate(systemConfigPath, "cli/agent.conf"); // Apply the config based on the required action levels for each service // Copy requiredActions_ before we reset it - this will be returned in @@ -708,12 +849,13 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { } // Only remove the session config after everything succeeded - fs::remove(sessionConfigPath_, ec); + ec = std::error_code{}; + fs::remove(sessionConfigPath, ec); if (ec) { // Log warning but don't fail - the commit succeeded LOG(WARNING) << fmt::format( "Failed to remove session config {}: {}", - sessionConfigPath_, + sessionConfigPath, ec.message()); } @@ -725,105 +867,175 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { return CommitResult{revision, actions}; } -int ConfigSession::rollback(const HostInfo& hostInfo) { - // Get the current revision number - int currentRevision = getCurrentRevisionNumber(systemConfigPath_); - if (currentRevision <= 0) { - throw std::runtime_error( - "Cannot rollback: cannot determine the current revision from " + - systemConfigPath_); - } else if (currentRevision == 1) { +void ConfigSession::rebase() { + if (!sessionExists()) { throw std::runtime_error( - "Cannot rollback: already at the first revision (r1)"); + "No config session exists. Make a config change first."); } - // Rollback to the previous revision - std::string targetRevision = "r" + std::to_string(currentRevision - 1); - return rollback(hostInfo, targetRevision); -} - -int ConfigSession::rollback( - const HostInfo& hostInfo, - const std::string& revision) { - ensureDirectoryExists(cliConfigDir_); - - // Build the path to the target revision - std::string targetConfigPath = - fmt::format("{}/agent-{}.conf", cliConfigDir_, revision); + std::string currentHead = git_->getHead(); - // Check if the target revision exists - if (!fs::exists(targetConfigPath)) { + // If base is empty or already matches HEAD, nothing to rebase + if (base_.empty() || base_ == currentHead) { throw std::runtime_error( - fmt::format( - "Revision {} does not exist at {}", revision, targetConfigPath)); + "No rebase needed: session is already based on the current HEAD."); } - std::error_code ec; - - // Verify that the system config is a symlink - if (!fs::is_symlink(systemConfigPath_)) { + // Get the three versions of the config: + // 1. Base config (what the session was originally based on) + // 2. Current HEAD config (what someone else committed) + // 3. Session config (user's changes) + std::string cliConfigRelPath = "cli/agent.conf"; + std::string baseConfig = git_->fileAtRevision(base_, cliConfigRelPath); + std::string headConfig = git_->fileAtRevision(currentHead, cliConfigRelPath); + + std::string sessionConfigPath = getSessionConfigPath(); + std::string sessionConfig; + if (!folly::readFile(sessionConfigPath.c_str(), sessionConfig)) { throw std::runtime_error( fmt::format( - "{} is not a symlink. Expected it to be a symlink.", - systemConfigPath_)); + "Failed to read session config from {}", sessionConfigPath)); } - // Read the old symlink target in case we need to undo the rollback - std::string oldSymlinkTarget = fs::read_symlink(systemConfigPath_, ec); - if (ec) { + // Parse all three as JSON + folly::dynamic baseJson = folly::parseJson(baseConfig); + folly::dynamic headJson = folly::parseJson(headConfig); + folly::dynamic sessionJson = folly::parseJson(sessionConfig); + + // Perform a 3-way merge + // For each key in session that differs from base, apply to head + // If head also changed the same key differently, that's a conflict + std::vector conflicts; + folly::dynamic mergedJson = + threeWayMerge(baseJson, headJson, sessionJson, "", conflicts); + + if (!conflicts.empty()) { + std::string conflictList; + for (const auto& conflict : conflicts) { + conflictList += "\n - " + conflict; + } throw std::runtime_error( fmt::format( - "Failed to read symlink {}: {}", systemConfigPath_, ec.message())); + "Rebase failed due to conflicts at the following paths:{}", + conflictList)); } - // First, create a new revision with the same content as the target revision - auto [newRevisionPath, newRevision] = - createNextRevisionFile(fmt::format("{}/agent", cliConfigDir_)); + // Write the merged config to the session file + std::string mergedConfigStr = folly::toPrettyJson(mergedJson); + folly::writeFileAtomic( + sessionConfigPath, mergedConfigStr, 0644, folly::SyncType::WITH_SYNC); - // Copy the target config to the new revision file - fs::copy_file( - targetConfigPath, - newRevisionPath, - fs::copy_options::overwrite_existing, - ec); - if (ec) { - // Clean up the revision file we created - fs::remove(newRevisionPath); + // Update the base to current HEAD + base_ = currentHead; + saveMetadata(); + + // Reload the config into memory + loadConfig(); +} + +std::string ConfigSession::rollback(const HostInfo& hostInfo) { + // Get the commit history to find the previous commit + auto commits = git_->log(getCliConfigPath(), 2); + if (commits.size() < 2) { throw std::runtime_error( - fmt::format( - "Failed to create new revision for rollback: {}", ec.message())); + "Cannot rollback: no previous revision available in Git history"); } - // Atomically update the symlink to point to the new revision - atomicSymlinkUpdate(systemConfigPath_, newRevisionPath); + // Rollback to the previous commit (second in the list) + return rollback(hostInfo, commits[1].sha1); +} - // Reload the config - if this fails, atomically undo the rollback +std::string ConfigSession::rollback( + const HostInfo& hostInfo, + const std::string& commitSha) { + std::string cliConfigDir = getCliConfigDir(); + std::string cliConfigPath = getCliConfigPath(); + std::string systemConfigPath = getSystemConfigPath(); + + ensureDirectoryExists(cliConfigDir); + + // Resolve the commit SHA (in case it's a short SHA or ref) + std::string resolvedSha = git_->resolveRef(commitSha); + + // Get the config and metadata content from the target commit + // The paths in git are relative to the repo root + std::string targetConfigData = + git_->fileAtRevision(resolvedSha, "cli/agent.conf"); + std::string targetMetadataData = + git_->fileAtRevision(resolvedSha, "cli/cli_metadata.json"); + std::string metadataPath = fmt::format("{}/cli_metadata.json", cliConfigDir); + + // Read the current config for rollback if needed + std::string oldConfigData; + if (fs::exists(cliConfigPath)) { + if (!folly::readFile(cliConfigPath.c_str(), oldConfigData)) { + throw std::runtime_error( + fmt::format("Failed to read current config from {}", cliConfigPath)); + } + } + std::string oldMetadataData; + if (fs::exists(metadataPath)) { + if (!folly::readFile(metadataPath.c_str(), oldMetadataData)) { + throw std::runtime_error( + fmt::format("Failed to read current metadata from {}", metadataPath)); + } + } + + // Atomically write the target config and metadata to the CLI directory + folly::writeFileAtomic( + cliConfigPath, targetConfigData, 0644, folly::SyncType::WITH_SYNC); + folly::writeFileAtomic( + metadataPath, targetMetadataData, 0644, folly::SyncType::WITH_SYNC); + + // Ensure the system config symlink points to the CLI config + atomicSymlinkUpdate(systemConfigPath, "cli/agent.conf"); + + // Reload the config - if this fails, restore the old config and metadata + // TODO: look at all the metadata files in the revision range and + // decide whether or not we need to restart the agent based on the highest + // action level in that range. + std::string newCommitSha; try { auto client = utils::createClient>( hostInfo); client->sync_reloadConfig(); + + // Create a Git commit for the rollback with all relevant files + std::string commitMessage = fmt::format( + "Rollback to {} by {}", resolvedSha.substr(0, 8), username_); + newCommitSha = git_->commit( + {cliConfigPath, metadataPath, systemConfigPath}, + commitMessage, + username_, + ""); + LOG(INFO) << "Rollback committed as " << newCommitSha.substr(0, 8); } catch (const std::exception& ex) { - // Rollback: atomically restore the old symlink + // Rollback: restore the old config and metadata try { - atomicSymlinkUpdate(systemConfigPath_, oldSymlinkTarget); + if (!oldConfigData.empty()) { + folly::writeFileAtomic( + cliConfigPath, oldConfigData, 0644, folly::SyncType::WITH_SYNC); + } + if (!oldMetadataData.empty()) { + folly::writeFileAtomic( + metadataPath, oldMetadataData, 0644, folly::SyncType::WITH_SYNC); + } } catch (const std::exception& rollbackEx) { // If rollback also fails, include both errors in the message throw std::runtime_error( fmt::format( - "Failed to reload config: {}. Additionally, failed to rollback the symlink: {}", + "Failed to reload config: {}. Additionally, failed to restore the old config: {}", ex.what(), rollbackEx.what())); } throw std::runtime_error( fmt::format( - "Failed to reload config, symlink was rolled back automatically: {}", + "Failed to reload config, config was restored automatically: {}", ex.what())); } - // Successfully rolled back - LOG(INFO) << "Rollback committed as revision r" << newRevision; - return newRevision; + return newCommitSha; } } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/session/ConfigSession.h b/fboss/cli/fboss2/session/ConfigSession.h index 88712cb7d481f..daf52f9f28113 100644 --- a/fboss/cli/fboss2/session/ConfigSession.h +++ b/fboss/cli/fboss2/session/ConfigSession.h @@ -13,9 +13,10 @@ #include #include #include +#include #include "fboss/agent/gen-cpp2/agent_config_types.h" -#include "fboss/agent/if/gen-cpp2/ctrl_types.h" #include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" +#include "fboss/cli/fboss2/session/Git.h" #include "fboss/cli/fboss2/utils/HostInfo.h" namespace facebook::fboss::utils { @@ -29,8 +30,8 @@ namespace facebook::fboss { * * OVERVIEW: * ConfigSession provides a session-based workflow for editing FBOSS agent - * configuration. It maintains one or more session files that can be edited - * and then atomically committed to the system configuration. + * configuration. It maintains a session file that can be edited and then + * atomically committed to the system configuration with Git version control. * * SINGLETON PATTERN: * ConfigSession is typically accessed via getInstance(), which currently @@ -51,35 +52,38 @@ namespace facebook::fboss { * To apply session changes to the system: * 1. User runs: fboss2 config session commit * 2. ConfigSession::commit() is called, which: - * a. Determines the next revision number (e.g., r5) - * b. Atomically writes session config to /etc/coop/cli/agent-r5.conf - * c. Atomically updates the /etc/coop/agent.conf symlink to point to - * agent-r5.conf and calls reloadConfig() on the wedge_agent to - * reload its configuration + * a. Atomically writes the session config to /etc/coop/cli/agent.conf + * b. Ensure /etc/coop/agent.conf is a symlink to /etc/coop/cli/agent.conf + * c. Creates a Git commit with the updated agent.conf and metadata + * d. Calls reloadConfig() on wedge_agent (or restarts it for + * AGENT_RESTART changes) * 3. The session file is cleared (ready for next edit session) * * ROLLBACK FLOW: * To revert to a previous configuration: - * 1. User runs: fboss2 config rollback [rN] + * 1. User runs: fboss2-dev config rollback [] * 2. ConfigSession::rollback() is called, which: - * a. Identifies the target revision (previous or specified) - * b. Atomically updates /etc/coop/agent.conf symlink to point to - * agent-rN.conf c. Calls wedge_agent to reload the configuration + * a. Reads the target revision's agent.conf from Git history + * b. Atomically writes it to /etc/coop/cli/agent.conf + * c. Creates a new Git commit indicating the rollback + * d. Calls wedge_agent to reload the configuration (or restarts + * it if necessary) * * CONFIGURATION FILES: * - Session file: ~/.fboss2/agent.conf (per-user, temporary edits) - * - System config: /agent.conf (symlink to current revision) - * - Revision files: /cli/agent-rN.conf (committed configs) + * - System config: /etc/coop/agent.conf (symlink to real config, Git-versioned) + * - CLI config: /etc/coop/cli/agent.conf (actual config file, Git-versioned) + * - Metadata: /etc/coop/cli/cli_metadata.json (commit metadata, Git-versioned) * - * Where is determined by AgentDirectoryUtil::getConfigDirectory() - * (typically /etc/coop, derived from the parent of the config directory path). + * VERSION CONTROL: + * The /etc/coop directory is a local Git repository. Each commit() creates + * a Git commit with the updated config. History is retrieved via git log, + * and rollback reads from Git history rather than using git revert. * * THREAD SAFETY: * ConfigSession is NOT thread-safe. It is designed for single-threaded CLI * command execution. The code is safe in face of concurrent usage from - * multiple processes, e.g. two users trying to commit a config at the same - * time should not lead to a partially committed config or any process being - * able to read a partially written file. + * multiple processes. */ class ConfigSession { public: @@ -90,15 +94,25 @@ class ConfigSession { // If no session exists, copies /etc/coop/agent.conf to ~/.fboss2/agent.conf static ConfigSession& getInstance(); + // Static path getters - can be called without creating a session instance. + // These are useful for checking if session files exist without triggering + // session initialization. + static std::string getSessionDir(); + static std::string getSessionConfigPathStatic(); + static std::string getSessionMetadataPathStatic(); + // Get the path to the session config file (~/.fboss2/agent.conf) std::string getSessionConfigPath() const; - // Get the path to the system config file (/etc/coop/agent.conf) + // Get the path to the system config file (/etc/coop/agent.conf symlink) std::string getSystemConfigPath() const; // Get the path to the CLI config directory (/etc/coop/cli) std::string getCliConfigDir() const; + // Get the path to the actual CLI config file (/etc/coop/cli/agent.conf) + std::string getCliConfigPath() const; + // Result of a commit operation struct CommitResult { int revision; // The revision number that was committed @@ -107,17 +121,24 @@ class ConfigSession { std::map actions; }; - // Atomically commit the session to /etc/coop/cli/agent-rN.conf, - // update the symlink /etc/coop/agent.conf to point to it. - // For HITLESS changes, also calls reloadConfig() on the agent. - // For AGENT_RESTART changes, does NOT call reloadConfig() - user must restart - // agent. Returns CommitResult with revision number and action level. + // Atomically commit the session to /etc/coop/cli/agent.conf and create a git + // commit. For HITLESS changes, also calls reloadConfig() on the agent. + // For AGENT_RESTART changes, restarts the agent via systemd. + // Returns CommitResult with git commit SHA and action level. CommitResult commit(const HostInfo& hostInfo); - // Rollback to a specific revision or to the previous revision - // Returns the revision that was rolled back to - int rollback(const HostInfo& hostInfo); - int rollback(const HostInfo& hostInfo, const std::string& revision); + // Rebase the session onto the current HEAD. + // This is needed when someone else has committed changes while this session + // was in progress. It computes the diff between the base config and the + // session config, then applies that diff on top of the current HEAD. + // Throws std::runtime_error if there are conflicts that cannot be resolved. + void rebase(); + + // Rollback to a specific revision (git commit SHA) or to the previous + // revision Returns the git commit SHA of the new commit created for the + // rollback + std::string rollback(const HostInfo& hostInfo); + std::string rollback(const HostInfo& hostInfo, const std::string& commitSha); // Check if a session exists bool sessionExists() const; @@ -136,9 +157,10 @@ class ConfigSession { // This combines saving the config and updating its associated metadata. void saveConfig(cli::ServiceType service, cli::ConfigActionLevel actionLevel); - // Extract revision number from a filename or path like "agent-r42.conf" - // Returns -1 if the filename doesn't match the expected pattern - static int extractRevisionNumber(const std::string& filenameOrPath); + // Get the Git instance for this config session + // Used to access the Git repository for history, rollback, etc. + Git& getGit(); + const Git& getGit() const; // Update the required action level for the current session. // Tracks the highest action level across all config commands. @@ -157,22 +179,28 @@ class ConfigSession { // Get the systemd service name for a service type static std::string getServiceName(cli::ServiceType service); + // Get the list of commands executed in this session + const std::vector& getCommands() const; + protected: // Constructor for testing with custom paths - ConfigSession( - const std::string& sessionConfigPath, - const std::string& systemConfigPath, - const std::string& cliConfigDir); + ConfigSession(std::string sessionConfigDir, std::string systemConfigDir); // Set the singleton instance (for testing only) static void setInstance(std::unique_ptr instance); + // Add a command to the history (for testing only) + // This allows tests to simulate command tracking without /proc/self/cmdline + void addCommand(const std::string& command); + private: - std::string sessionConfigPath_; - std::string systemConfigPath_; - std::string cliConfigDir_; + std::string sessionConfigDir_; // Typically ~/.fboss2 + std::string systemConfigDir_; // Typically /etc/coop std::string username_; + // Git instance for version control operations + std::unique_ptr git_; + // Lazy-initialized configuration and port map cfg::AgentConfig agentConfig_; std::unique_ptr portMap_; @@ -183,12 +211,23 @@ class ConfigSession { // session. std::map requiredActions_; - // Path to the metadata file (e.g., ~/.fboss2/metadata) + // List of commands executed in this session, persisted to disk + std::vector commands_; + + // Git commit SHA that this session is based on (captured when session is + // created). Used to detect if someone else committed changes while this + // session was in progress. + std::string base_; + + // Path to the system metadata file (in the Git repo) + std::string getSystemMetadataPath() const; + + // Path to the session metadata file (in the user's home directory) std::string getMetadataPath() const; - // Load/save action levels from/to disk - void loadActionLevel(); - void saveActionLevel(); + // Load/save metadata (action levels and commands) from disk + void loadMetadata(); + void saveMetadata(); // Restart a service via systemd and wait for it to be active // For AGENT_WARMBOOT, does a simple restart. @@ -208,8 +247,11 @@ class ConfigSession { // Initialize the session (creates session config file if it doesn't exist) void initializeSession(); - void copySystemConfigToSession(); + void copySystemConfigToSession() const; void loadConfig(); + + // Initialize the Git repository if needed + void initializeGit(); }; } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/session/Git.cpp b/fboss/cli/fboss2/session/Git.cpp new file mode 100644 index 0000000000000..606cf9cb5ddab --- /dev/null +++ b/fboss/cli/fboss2/session/Git.cpp @@ -0,0 +1,312 @@ +/* + * 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/session/Git.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +// RAII class to temporarily set umask for group writeability +// This ensures that files and directories created by Git are group-writable. +class ScopedUmask { + public: + explicit ScopedUmask(mode_t newMask) { + oldMask_ = umask(newMask); + } + ~ScopedUmask() { + umask(oldMask_); + } + ScopedUmask(const ScopedUmask&) = delete; + ScopedUmask& operator=(const ScopedUmask&) = delete; + ScopedUmask(ScopedUmask&&) = delete; + ScopedUmask& operator=(ScopedUmask&&) = delete; + + private: + mode_t oldMask_; +}; + +// RAII class to acquire an exclusive flock on a file. +// The lock is automatically released when the file descriptor is closed, +// which also happens when the process exits (even on SIGKILL or crash). +class ScopedFileLock { + public: + explicit ScopedFileLock(const std::string& lockPath) { + fd_ = open(lockPath.c_str(), O_CREAT | O_RDWR, 0664); + if (fd_ < 0) { + throw std::runtime_error( + fmt::format( + "Failed to open lock file {}: {}", + lockPath, + folly::errnoStr(errno))); + } + if (flock(fd_, LOCK_EX) < 0) { + int savedErrno = errno; + close(fd_); + throw std::runtime_error( + fmt::format( + "Failed to acquire lock on {}: {}", + lockPath, + folly::errnoStr(savedErrno))); + } + } + ~ScopedFileLock() { + if (fd_ >= 0) { + flock(fd_, LOCK_UN); + close(fd_); + } + } + ScopedFileLock(const ScopedFileLock&) = delete; + ScopedFileLock& operator=(const ScopedFileLock&) = delete; + ScopedFileLock(ScopedFileLock&&) = delete; + ScopedFileLock& operator=(ScopedFileLock&&) = delete; + + private: + int fd_ = -1; +}; + +// Result of running a git command. +struct CommandResult { + std::string stdoutStr; + std::string stderrStr; + int exitStatus; +}; + +CommandResult runGitCommandWithStatus( + const std::string& repoPath, + const std::vector& args) { + // Use full path to git to avoid PATH issues in test environments + // Pass -c safe.directory= to handle cases where the repository + // is owned by a different user (e.g., /etc/coop owned by root) + std::vector fullArgs = { + "/usr/bin/git", "-c", "safe.directory=" + repoPath, "-C", repoPath}; + fullArgs.insert(fullArgs.end(), args.begin(), args.end()); + + try { + folly::Subprocess proc( + fullArgs, folly::Subprocess::Options().pipeStdout().pipeStderr()); + + auto output = proc.communicate(); + int exitStatus = proc.wait().exitStatus(); + + return {output.first, output.second, exitStatus}; + } catch (const std::exception& ex) { + throw std::runtime_error( + fmt::format("Failed to execute git command: {}", ex.what())); + } +} + +} // namespace + +namespace facebook::fboss { + +Git::Git(std::string repoPath) : repoPath_(std::move(repoPath)) {} + +std::string Git::getRepoPath() const { + return repoPath_; +} + +bool Git::isRepository() const { + auto result = runGitCommandWithStatus(repoPath_, {"rev-parse", "--git-dir"}); + return result.exitStatus == 0; +} + +void Git::init(const std::string& initialBranch) { + if (isRepository()) { + return; // Already a repository + } + + // Set umask to allow group write access (0002 means: keep all bits except + // "other write"). This ensures .git directory and its contents are + // group-writable when /etc/coop is group-writable (e.g., owned by root:admin) + ScopedUmask scopedUmask(0002); + + // Create the directory if it doesn't exist + std::error_code ec; + fs::create_directories(repoPath_, ec); + if (ec) { + throw std::runtime_error( + fmt::format( + "Failed to create directory {}: {}", repoPath_, ec.message())); + } + + // Initialize the repository with the specified initial branch + // Use --shared=group to make the repository group-writable + runGitCommand( + {"init", "--initial-branch=" + initialBranch, "--shared=group"}); + + // Configure user.name and user.email locally to avoid git config issues + // Use "fboss-cli" as the default identity for automated commits + runGitCommand({"config", "user.name", "fboss-cli"}); + runGitCommand({"config", "user.email", "fboss-cli@localhost"}); +} + +std::string Git::commit( + const std::vector& files, + const std::string& message, + const std::string& authorName, + const std::string& authorEmail) { + if (files.empty()) { + throw std::runtime_error("No files specified for commit"); + } + if (message.empty()) { + throw std::runtime_error("Commit message cannot be empty"); + } + + // Acquire an exclusive lock to serialize concurrent commits. + // This prevents race conditions where two processes could interleave + // their git add and git commit operations. + std::string lockPath = repoPath_ + "/.git/fboss2-commit.lock"; + ScopedFileLock lock(lockPath); + + // First, add the files to the index + // This handles both tracked and untracked files + std::vector addArgs = {"add", "--"}; + for (const auto& file : files) { + addArgs.push_back(file); + } + runGitCommand(addArgs); + + // Build the commit command + std::vector args = {"commit", "-m", message}; + + // If author is specified, use --author + if (!authorName.empty() || !authorEmail.empty()) { + std::string author = fmt::format( + "{} <{}>", + authorName.empty() ? "fboss-cli" : authorName, + authorEmail.empty() ? "fboss-cli@localhost" : authorEmail); + args.push_back("--author=" + author); + } + + runGitCommand(args); + + // Return the SHA of the new commit + return getHead(); +} + +std::vector Git::log(const std::string& filePath, size_t limit) + const { + // Use a custom format with null byte separators to parse reliably + // Format: SHA1, author_name, author_email, timestamp, subject + // Use %s (subject) instead of %B (body) to get just the first line + std::vector args = { + "log", "--format=%H%x00%an%x00%ae%x00%at%x00%s%x00", "--", filePath}; + + if (limit > 0) { + args.insert(args.begin() + 1, "-n"); + args.insert(args.begin() + 2, std::to_string(limit)); + } + + auto result = runGitCommandWithStatus(repoPath_, args); + if (result.exitStatus != 0) { + // No commits or file not in repo + return {}; + } + + std::vector commits; + + // Split output by null characters + std::vector parts; + folly::split('\0', result.stdoutStr, parts); + + // Each commit has 5 fields, so process in groups of 5 + // The last empty part after final \0 is ignored + for (size_t i = 0; i + 4 < parts.size(); i += 5) { + GitCommit commit; + + // Trim all fields - git log output can have newlines between commits + commit.sha1 = folly::trimWhitespace(parts[i]).str(); + commit.authorName = folly::trimWhitespace(parts[i + 1]).str(); + commit.authorEmail = folly::trimWhitespace(parts[i + 2]).str(); + + std::string timestampStr = folly::trimWhitespace(parts[i + 3]).str(); + if (timestampStr.empty()) { // Should never happen. + throw std::runtime_error( + fmt::format( + "Git log returned empty timestamp for commit {}", commit.sha1)); + } + commit.timestamp = std::stoll(timestampStr); + + commit.subject = folly::trimWhitespace(parts[i + 4]).str(); + + // Skip empty commits (can happen if there are extra null separators) + if (commit.sha1.empty()) { + continue; + } + + commits.push_back(std::move(commit)); + } + + return commits; +} + +std::string Git::fileAtRevision( + const std::string& revision, + const std::string& filePath) const { + // Use git show to get file contents at a specific revision + std::string ref = fmt::format("{}:{}", revision, filePath); + return runGitCommand({"show", ref}); +} + +std::string Git::resolveRef(const std::string& ref) const { + return runGitCommand({"rev-parse", folly::trimWhitespace(ref).str()}); +} + +std::string Git::getHead() const { + auto result = runGitCommandWithStatus(repoPath_, {"rev-parse", "HEAD"}); + if (result.exitStatus != 0) { + return ""; // No commits yet + } + return folly::trimWhitespace(result.stdoutStr).str(); +} + +bool Git::hasCommits() const { + // Check if HEAD can be resolved - if not, there are no commits + auto result = runGitCommandWithStatus(repoPath_, {"rev-parse", "HEAD"}); + return result.exitStatus == 0; +} + +std::string Git::runGitCommand(const std::vector& args) const { + auto result = runGitCommandWithStatus(repoPath_, args); + if (result.exitStatus != 0) { + std::string cmd = "git"; + for (const auto& arg : args) { + cmd += " " + arg; + } + throw std::runtime_error( + fmt::format( + "Git command failed: {} (exit {}): {}", + cmd, + result.exitStatus, + folly::trimWhitespace(result.stderrStr))); + } + return folly::trimWhitespace(result.stdoutStr).str(); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/session/Git.h b/fboss/cli/fboss2/session/Git.h new file mode 100644 index 0000000000000..8e59e1b28723d --- /dev/null +++ b/fboss/cli/fboss2/session/Git.h @@ -0,0 +1,146 @@ +/* + * 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 + +namespace facebook::fboss { + +/** + * Represents a single commit in the Git log. + */ +struct GitCommit { + std::string sha1; // Full 40-character SHA1 hash + std::string authorName; + std::string authorEmail; + int64_t timestamp; // Unix timestamp in seconds + std::string subject; // First line of commit message +}; + +/** + * Git provides a simple interface for Git operations in a local repository. + * + * This class is designed to be: + * 1. Easy to use for common operations like init, commit, log, and show + * 2. Easy to mock for unit tests + * 3. Thread-safe for concurrent usage (all operations are atomic) + * + * The commit() method uses git add followed by git commit. This two-step + * process is required to handle both tracked and untracked files, as git + * commit --include only works for already-tracked files. + */ +class Git { + public: + /** + * Create a Git instance for the specified repository path. + * @param repoPath The path to the Git repository (or where to create one) + */ + explicit Git(std::string repoPath); + ~Git() = default; + + // Non-copyable and non-movable + Git(const Git&) = delete; + Git& operator=(const Git&) = delete; + Git(Git&&) = delete; + Git& operator=(Git&&) = delete; + + /** + * Get the repository path. + */ + std::string getRepoPath() const; + + /** + * Check if the repository path is already a Git repository. + */ + bool isRepository() const; + + /** + * Initialize a new Git repository if one doesn't exist. + * If the repository already exists, this is a no-op. + * @param initialBranch The name of the initial branch (default: "main") + */ + void init(const std::string& initialBranch = "main"); + + /** + * Commit the specified files with the given message. + * This uses git commit --include to add files without using git add. + * + * @param files List of file paths (relative to repo root) to commit + * @param message Commit message + * @param authorName Author name (optional, uses system default if empty) + * @param authorEmail Author email (optional, uses system default if empty) + * @return The SHA1 of the new commit + * @throws std::runtime_error if the commit fails + */ + std::string commit( + const std::vector& files, + const std::string& message, + const std::string& authorName = "", + const std::string& authorEmail = ""); + + /** + * Get the commit log for a specific file, optionally limited to N entries. + * + * @param filePath Path to the file (relative to repo root) + * @param limit Maximum number of commits to return (0 = no limit) + * @return Vector of GitCommit objects, most recent first + */ + std::vector log(const std::string& filePath, size_t limit = 0) + const; + + /** + * Get the contents of a file at a specific revision. + * + * @param revision A Git revision: commit SHA (full or abbreviated), + * branch name, tag name, or other git syntax (e.g. HEAD~4) + * @param filePath Path to the file (relative to repo root) + * @return The file contents at that revision + * @throws std::runtime_error if the file or revision doesn't exist + */ + std::string fileAtRevision( + const std::string& revision, + const std::string& filePath) const; + + /** + * Get the full SHA for a commit reference (SHA, HEAD, branch name, etc). + * + * @param ref Git reference (commit SHA, HEAD, branch name, etc) + * @return Full SHA of the commit + * @throws std::runtime_error if the reference is invalid + */ + std::string resolveRef(const std::string& ref) const; + + /** + * Get the current HEAD commit SHA. + * @return Full SHA of HEAD, or empty string if repo has no commits + */ + std::string getHead() const; + + /** + * Check if the repository has any commits. + * @return true if the repository has at least one commit, false otherwise + */ + bool hasCommits() const; + + private: + /** + * Execute a git command and return its output. + * @param args Arguments to pass to git (not including 'git' itself) + * @return The stdout output of the command + * @throws std::runtime_error if the command fails + */ + std::string runGitCommand(const std::vector& args) const; + + std::string repoPath_; +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index e00e3226cc278..6bb09a19ee2df 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -65,13 +65,18 @@ cpp_unittest( srcs = [ "CmdConfigAppliedInfoTest.cpp", "CmdConfigHistoryTest.cpp", - "CmdConfigInterfaceDescriptionTest.cpp", - "CmdConfigInterfaceMtuTest.cpp", + "CmdConfigInterfaceSwitchportAccessVlanTest.cpp", + "CmdConfigL2LearningModeTest.cpp", + "CmdConfigInterfaceTest.cpp", "CmdConfigQosBufferPoolTest.cpp", "CmdConfigReloadTest.cpp", + "CmdConfigSessionClearTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", + "CmdConfigVlanPortTaggingModeTest.cpp", + "CmdConfigVlanStaticMacTest.cpp", "CmdGetPcapTest.cpp", + "CmdListConfigTest.cpp", "CmdSetPortStateTest.cpp", "CmdShowAclTest.cpp", "CmdShowAgentSslTest.cpp", @@ -99,10 +104,12 @@ cpp_unittest( "CmdShowProductTest.cpp", "CmdShowRouteDetailsTest.cpp", "CmdShowRouteSummaryTest.cpp", + "CmdShowRunningConfigTest.cpp", "CmdShowTeFlowTest.cpp", "CmdShowTransceiverTest.cpp", "CmdStartPcapTest.cpp", "CmdStopPcapTest.cpp", + "GitTest.cpp", "PortMapTest.cpp", ], # Config files for PortMapTest parameterized tests diff --git a/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp b/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp index 5db56df97a947..adb6285334bfe 100644 --- a/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp @@ -1,18 +1,17 @@ // (c) Facebook, Inc. and its affiliates. Confidential and proprietary. -#include -#include +#include +#include #include +#include +#include #include #include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" -#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" #include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" #include "fboss/cli/fboss2/test/TestableConfigSession.h" -#include "fboss/cli/fboss2/utils/PortMap.h" - -#include -#include +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) namespace fs = std::filesystem; @@ -24,9 +23,8 @@ class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { public: fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; - fs::path sessionConfigPath_; - fs::path cliConfigDir_; + fs::path systemConfigDir_; // /etc/coop (git repo root) + fs::path sessionConfigDir_; // ~/.fboss2 void SetUp() override { CmdHandlerTestBase::SetUp(); @@ -49,16 +47,23 @@ class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { fs::create_directories(testEtcDir_); // Set up paths - systemConfigPath_ = testEtcDir_ / "agent.conf"; - sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; - cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + // Structure: systemConfigDir_ = /etc/coop (git repo root) + // - agent.conf (symlink -> cli/agent.conf) + // - cli/agent.conf (actual config file) + systemConfigDir_ = testEtcDir_ / "coop"; + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Create CLI config directory - fs::create_directories(cliConfigDir_); + fs::create_directories(systemConfigDir_ / "cli"); - // Create a default system config + // Initialize Git repository + Git git(systemConfigDir_.string()); + git.init(); + + // Create the actual config file at cli/agent.conf createTestConfig( - systemConfigPath_, + cliConfigPath, R"({ "sw": { "ports": [ @@ -71,6 +76,12 @@ class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { ] } })"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Create initial commit + git.commit({"cli/agent.conf"}, "Initial commit"); } void TearDown() override { @@ -98,10 +109,10 @@ class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { } void initializeTestSession() { - // Ensure system config exists before initializing session - if (!fs::exists(systemConfigPath_)) { + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + if (!fs::exists(cliConfigPath)) { createTestConfig( - systemConfigPath_, + cliConfigPath, R"({ "sw": { "ports": [ @@ -115,86 +126,85 @@ class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { } })"); } - - // Ensure the parent directory of session config exists - fs::create_directories(sessionConfigPath_.parent_path()); - - // Initialize ConfigSession singleton with test paths + fs::create_directories(sessionConfigDir_); TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); } }; TEST_F(CmdConfigHistoryTestFixture, historyListsRevisions) { - // Create revision files with valid config content - createTestConfig( - cliConfigDir_ / "agent-r1.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + Git git(systemConfigDir_.string()); + + // Second commit createTestConfig( - cliConfigDir_ / "agent-r2.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + cliConfigPath, + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 200000}]}})"); + git.commit({"cli/agent.conf"}, "Second commit"); + + // Third commit createTestConfig( - cliConfigDir_ / "agent-r3.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + cliConfigPath, + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 300000}]}})"); + git.commit({"cli/agent.conf"}, "Third commit"); - // Initialize ConfigSession singleton with test paths initializeTestSession(); - // Create and execute the command auto cmd = CmdConfigHistory(); auto result = cmd.queryClient(localhost()); - // Verify the output contains all three revisions - EXPECT_NE(result.find("r1"), std::string::npos); - EXPECT_NE(result.find("r2"), std::string::npos); - EXPECT_NE(result.find("r3"), std::string::npos); - - // Verify table headers are present - EXPECT_NE(result.find("Revision"), std::string::npos); - EXPECT_NE(result.find("Owner"), std::string::npos); + EXPECT_NE(result.find("Initial commit"), std::string::npos); + EXPECT_NE(result.find("Second commit"), std::string::npos); + EXPECT_NE(result.find("Third commit"), std::string::npos); + EXPECT_NE(result.find("Commit"), std::string::npos); + EXPECT_NE(result.find("Author"), std::string::npos); EXPECT_NE(result.find("Commit Time"), std::string::npos); + EXPECT_NE(result.find("Message"), std::string::npos); + + // Verify the timestamp is formatted correctly (not epoch). + // Git returns Unix timestamps in seconds, so if the code incorrectly + // treats them as nanoseconds, we'd see dates near the Unix epoch. + // Depending on timezone, epoch could show as 1970-01-01 or 1969-12-31. + EXPECT_EQ(result.find("1970-"), std::string::npos) + << "Timestamp appears to be incorrectly parsed (showing 1970 epoch)"; + EXPECT_EQ(result.find("1969-"), std::string::npos) + << "Timestamp appears to be incorrectly parsed (showing 1969 epoch)"; + // Check that the current year appears in the output + std::time_t now = std::time(nullptr); + std::tm tm{}; + localtime_r(&now, &tm); + std::string currentYear = std::to_string(1900 + tm.tm_year); + EXPECT_NE(result.find(currentYear + "-"), std::string::npos) + << "Expected timestamp with year " << currentYear << ", got: " << result; } -TEST_F(CmdConfigHistoryTestFixture, historyIgnoresNonMatchingFiles) { - // Create valid revision files - createTestConfig( - cliConfigDir_ / "agent-r1.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); +TEST_F(CmdConfigHistoryTestFixture, historyShowsOnlyConfigFileCommits) { + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + Git git(systemConfigDir_.string()); + + // Second commit for cli/agent.conf createTestConfig( - cliConfigDir_ / "agent-r2.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + cliConfigPath, + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 200000}]}})"); + git.commit({"cli/agent.conf"}, "Config update"); - // Create files that should be ignored - createTestConfig(cliConfigDir_ / "agent.conf.bak", R"({"backup": true})"); - createTestConfig(cliConfigDir_ / "other-r1.conf", R"({"other": true})"); - createTestConfig(cliConfigDir_ / "agent-r1.txt", R"({"wrong_ext": true})"); - createTestConfig(cliConfigDir_ / "agent-rX.conf", R"({"invalid": true})"); + // Create and commit a different file (should not appear in history) + createTestConfig(systemConfigDir_ / "other.txt", "other content"); + git.commit({"other.txt"}, "Other file commit"); - // Initialize ConfigSession singleton with test paths initializeTestSession(); - // Create and execute the command auto cmd = CmdConfigHistory(); auto result = cmd.queryClient(localhost()); - // Verify only valid revisions are listed - EXPECT_NE(result.find("r1"), std::string::npos); - EXPECT_NE(result.find("r2"), std::string::npos); - - // Verify invalid files are not listed - EXPECT_EQ(result.find("agent.conf.bak"), std::string::npos); - EXPECT_EQ(result.find("other-r1.conf"), std::string::npos); - EXPECT_EQ(result.find("agent-r1.txt"), std::string::npos); - EXPECT_EQ(result.find("rX"), std::string::npos); + EXPECT_NE(result.find("Initial commit"), std::string::npos); + EXPECT_NE(result.find("Config update"), std::string::npos); + // The "Other file commit" should not appear since it doesn't touch agent.conf + EXPECT_EQ(result.find("Other file commit"), std::string::npos); } -TEST_F(CmdConfigHistoryTestFixture, historyEmptyDirectory) { - // Directory exists but has no revision files - EXPECT_TRUE(fs::exists(cliConfigDir_)); - +TEST_F(CmdConfigHistoryTestFixture, historyShowsCommitShas) { // Initialize ConfigSession singleton with test paths initializeTestSession(); @@ -202,47 +212,63 @@ TEST_F(CmdConfigHistoryTestFixture, historyEmptyDirectory) { auto cmd = CmdConfigHistory(); auto result = cmd.queryClient(localhost()); - // Verify the output indicates no revisions found - EXPECT_NE(result.find("No config revisions found"), std::string::npos); - EXPECT_NE(result.find(cliConfigDir_.string()), std::string::npos); + // Verify the output contains a commit SHA (8 hex characters) + // The SHA should be in the first column + bool foundSha = false; + for (size_t i = 0; i + 7 < result.size(); ++i) { + bool isHex = true; + for (size_t j = 0; j < 8 && isHex; ++j) { + char c = result[i + j]; + if (!std::isxdigit(c)) { + isHex = false; + } + } + if (isHex) { + foundSha = true; + break; + } + } + EXPECT_TRUE(foundSha) << "Expected to find a commit SHA in the output"; } -TEST_F(CmdConfigHistoryTestFixture, historyNonSequentialRevisions) { - // Create non-sequential revision files (e.g., after deletions) - createTestConfig( - cliConfigDir_ / "agent-r1.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); - createTestConfig( - cliConfigDir_ / "agent-r5.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); - createTestConfig( - cliConfigDir_ / "agent-r10.conf", - R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); +TEST_F(CmdConfigHistoryTestFixture, historyMultipleCommits) { + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + Git git(systemConfigDir_.string()); + + // Create 5 more commits + for (int i = 2; i <= 6; ++i) { + createTestConfig( + cliConfigPath, + fmt::format( + R"({{"sw": {{"ports": [{{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": {}}}]}}}})", + i * 100000)); + git.commit({"cli/agent.conf"}, fmt::format("Commit {}", i)); + } - // Initialize ConfigSession singleton with test paths initializeTestSession(); - // Create and execute the command auto cmd = CmdConfigHistory(); auto result = cmd.queryClient(localhost()); - // Verify all revisions are listed in order - EXPECT_NE(result.find("r1"), std::string::npos); - EXPECT_NE(result.find("r5"), std::string::npos); - EXPECT_NE(result.find("r10"), std::string::npos); - - // Verify they appear in ascending order (r1 before r5 before r10) - auto pos_r1 = result.find("r1"); - auto pos_r5 = result.find("r5"); - auto pos_r10 = result.find("r10"); - EXPECT_LT(pos_r1, pos_r5); - EXPECT_LT(pos_r5, pos_r10); + EXPECT_NE(result.find("Commit 6"), std::string::npos); + EXPECT_NE(result.find("Commit 5"), std::string::npos); + EXPECT_NE(result.find("Commit 4"), std::string::npos); + EXPECT_NE(result.find("Commit 3"), std::string::npos); + EXPECT_NE(result.find("Commit 2"), std::string::npos); + EXPECT_NE(result.find("Initial commit"), std::string::npos); + + // Verify they appear in reverse chronological order (most recent first) + auto pos_6 = result.find("Commit 6"); + auto pos_5 = result.find("Commit 5"); + auto pos_initial = result.find("Initial commit"); + EXPECT_LT(pos_6, pos_5); + EXPECT_LT(pos_5, pos_initial); } TEST_F(CmdConfigHistoryTestFixture, printOutput) { auto cmd = CmdConfigHistory(); std::string tableOutput = - "Revision Owner Commit Time\nr1 user1 2024-01-01 12:00:00.000"; + "Commit Author Commit Time Message\nabcd1234 user1 2024-01-01 12:00:00 Initial commit"; // Redirect cout to capture output std::stringstream buffer; @@ -255,8 +281,8 @@ TEST_F(CmdConfigHistoryTestFixture, printOutput) { std::string output = buffer.str(); - EXPECT_NE(output.find("Revision"), std::string::npos); - EXPECT_NE(output.find("r1"), std::string::npos); + EXPECT_NE(output.find("Commit"), std::string::npos); + EXPECT_NE(output.find("abcd1234"), std::string::npos); EXPECT_NE(output.find("user1"), std::string::npos); } diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp deleted file mode 100644 index 777034cdb028d..0000000000000 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp +++ /dev/null @@ -1,212 +0,0 @@ -// (c) Facebook, Inc. and its affiliates. Confidential and proprietary. - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h" -#include "fboss/cli/fboss2/session/ConfigSession.h" -#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" -#include "fboss/cli/fboss2/test/TestableConfigSession.h" -#include "fboss/cli/fboss2/utils/InterfaceList.h" -#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) - -namespace fs = std::filesystem; - -using namespace ::testing; - -namespace facebook::fboss { - -class CmdConfigInterfaceDescriptionTestFixture : public CmdHandlerTestBase { - public: - void SetUp() override { - CmdHandlerTestBase::SetUp(); - - // Create unique test directories - auto tempBase = fs::temp_directory_path(); - auto uniquePath = boost::filesystem::unique_path( - "fboss_description_test_%%%%-%%%%-%%%%-%%%%"); - testHomeDir_ = tempBase / (uniquePath.string() + "_home"); - testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); - - std::error_code ec; - if (fs::exists(testHomeDir_)) { - fs::remove_all(testHomeDir_, ec); - } - if (fs::exists(testEtcDir_)) { - fs::remove_all(testEtcDir_, ec); - } - - // Create test directories - fs::create_directories(testHomeDir_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); - - // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) - setenv("HOME", testHomeDir_.c_str(), 1); - // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) - setenv("USER", "testuser", 1); - - // Create a test system config file as agent-r1.conf in the cli directory - fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; - createTestConfig(initialRevision, R"({ - "sw": { - "ports": [ - { - "logicalID": 1, - "name": "eth1/1/1", - "state": 2, - "speed": 100000, - "description": "original description of eth1/1/1" - }, - { - "logicalID": 2, - "name": "eth1/2/1", - "state": 2, - "speed": 100000, - "description": "original description of eth1/2/1" - } - ] - } -})"); - - // Create symlink at agent.conf pointing to agent-r1.conf - systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; - fs::create_symlink(initialRevision, systemConfigPath_); - - // Initialize the ConfigSession singleton for all tests - fs::path sessionConfig = testHomeDir_ / ".fboss2" / "agent.conf"; - TestableConfigSession::setInstance( - std::make_unique( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string())); - } - - void TearDown() override { - // Reset the singleton to ensure tests don't interfere with each other - TestableConfigSession::setInstance(nullptr); - std::error_code ec; - if (fs::exists(testHomeDir_)) { - fs::remove_all(testHomeDir_, ec); - } - if (fs::exists(testEtcDir_)) { - fs::remove_all(testEtcDir_, ec); - } - CmdHandlerTestBase::TearDown(); - } - - protected: - void createTestConfig(const fs::path& path, const std::string& content) { - std::ofstream file(path); - file << content; - file.close(); - } - - fs::path testHomeDir_; - fs::path testEtcDir_; - fs::path systemConfigPath_; -}; - -// Test setting description on a single existing interface -TEST_F(CmdConfigInterfaceDescriptionTestFixture, singleInterface) { - auto cmd = CmdConfigInterfaceDescription(); - utils::InterfaceList interfaces({"eth1/1/1"}); - - auto result = cmd.queryClient( - localhost(), interfaces, std::vector{"New description"}); - - EXPECT_THAT(result, HasSubstr("Successfully set description")); - EXPECT_THAT(result, HasSubstr("eth1/1/1")); - - // Verify the description was updated - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& ports = *switchConfig.ports(); - for (const auto& port : ports) { - if (*port.name() == "eth1/1/1") { - EXPECT_EQ(*port.description(), "New description"); - } else { - // Other ports should be unchanged - EXPECT_EQ(*port.description(), "original description of " + *port.name()); - } - } -} - -// Test setting description on a single non-existent interface -TEST_F(CmdConfigInterfaceDescriptionTestFixture, nonExistentInterface) { - // Creating InterfaceList with a non-existent port should throw - EXPECT_THROW(utils::InterfaceList({"eth1/99/1"}), std::invalid_argument); - - // Verify the config was not changed - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& ports = *switchConfig.ports(); - for (const auto& port : ports) { - EXPECT_EQ(*port.description(), "original description of " + *port.name()); - } -} - -// Test setting description on two valid interfaces at once -TEST_F(CmdConfigInterfaceDescriptionTestFixture, twoValidInterfacesAtOnce) { - auto cmd = CmdConfigInterfaceDescription(); - utils::InterfaceList interfaces({"eth1/1/1", "eth1/2/1"}); - - auto result = cmd.queryClient( - localhost(), interfaces, std::vector{"Shared description"}); - - EXPECT_THAT(result, HasSubstr("Successfully set description")); - EXPECT_THAT(result, HasSubstr("eth1/1/1")); - EXPECT_THAT(result, HasSubstr("eth1/2/1")); - - // Verify both descriptions were updated - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& ports = *switchConfig.ports(); - for (const auto& port : ports) { - if (*port.name() == "eth1/1/1" || *port.name() == "eth1/2/1") { - EXPECT_EQ(*port.description(), "Shared description"); - } - } -} - -// Test that mixing valid and invalid interfaces fails without changing anything -TEST_F(CmdConfigInterfaceDescriptionTestFixture, mixValidInvalidInterfaces) { - // Creating InterfaceList with one valid and one invalid port should throw - // because InterfaceList validates all ports before returning - EXPECT_THROW( - utils::InterfaceList({"eth1/1/1", "eth1/99/1"}), std::invalid_argument); - - // Verify no config changes were made - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& ports = *switchConfig.ports(); - for (const auto& port : ports) { - EXPECT_EQ(*port.description(), "original description of " + *port.name()); - } -} - -// Test that empty interface list throws -TEST_F(CmdConfigInterfaceDescriptionTestFixture, emptyInterfaceList) { - auto cmd = CmdConfigInterfaceDescription(); - utils::InterfaceList emptyInterfaces({}); - EXPECT_THROW( - cmd.queryClient( - localhost(), - emptyInterfaces, - std::vector{"Some description"}), - std::invalid_argument); -} - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp deleted file mode 100644 index 5656b68f5707b..0000000000000 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp +++ /dev/null @@ -1,218 +0,0 @@ -// (c) Facebook, Inc. and its affiliates. Confidential and proprietary. - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h" -#include "fboss/cli/fboss2/session/ConfigSession.h" -#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" -#include "fboss/cli/fboss2/test/TestableConfigSession.h" -#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" -#include "fboss/cli/fboss2/utils/InterfaceList.h" -#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) - -namespace fs = std::filesystem; - -using namespace ::testing; - -namespace facebook::fboss { - -class CmdConfigInterfaceMtuTestFixture : public CmdHandlerTestBase { - public: - void SetUp() override { - CmdHandlerTestBase::SetUp(); - - // Create unique test directories - auto tempBase = fs::temp_directory_path(); - auto uniquePath = - boost::filesystem::unique_path("fboss_mtu_test_%%%%-%%%%-%%%%-%%%%"); - testHomeDir_ = tempBase / (uniquePath.string() + "_home"); - testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); - - std::error_code ec; - if (fs::exists(testHomeDir_)) { - fs::remove_all(testHomeDir_, ec); - } - if (fs::exists(testEtcDir_)) { - fs::remove_all(testEtcDir_, ec); - } - - // Create test directories - fs::create_directories(testHomeDir_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); - - // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) - setenv("HOME", testHomeDir_.c_str(), 1); - // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) - setenv("USER", "testuser", 1); - - // Create a test system config file as agent-r1.conf in the cli directory - fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; - createTestConfig(initialRevision, R"({ - "sw": { - "ports": [ - { - "logicalID": 1, - "name": "eth1/1/1", - "state": 2, - "speed": 100000 - }, - { - "logicalID": 2, - "name": "eth1/2/1", - "state": 2, - "speed": 100000 - } - ], - "vlanPorts": [ - { - "vlanID": 1, - "logicalPort": 1 - }, - { - "vlanID": 2, - "logicalPort": 2 - } - ], - "interfaces": [ - { - "intfID": 1, - "routerID": 0, - "vlanID": 1, - "name": "eth1/1/1", - "mtu": 1500 - }, - { - "intfID": 2, - "routerID": 0, - "vlanID": 2, - "name": "eth1/2/1", - "mtu": 1500 - } - ] - } -})"); - - // Create symlink at agent.conf pointing to agent-r1.conf - systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; - fs::create_symlink(initialRevision, systemConfigPath_); - - // Initialize the ConfigSession singleton for all tests - fs::path sessionConfig = testHomeDir_ / ".fboss2" / "agent.conf"; - TestableConfigSession::setInstance( - std::make_unique( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string())); - } - - void TearDown() override { - // Reset the singleton to ensure tests don't interfere with each other - TestableConfigSession::setInstance(nullptr); - std::error_code ec; - if (fs::exists(testHomeDir_)) { - fs::remove_all(testHomeDir_, ec); - } - if (fs::exists(testEtcDir_)) { - fs::remove_all(testEtcDir_, ec); - } - CmdHandlerTestBase::TearDown(); - } - - protected: - void createTestConfig(const fs::path& path, const std::string& content) { - std::ofstream file(path); - file << content; - file.close(); - } - - fs::path testHomeDir_; - fs::path testEtcDir_; - fs::path systemConfigPath_; -}; - -// Test setting MTU on a single existing interface -TEST_F(CmdConfigInterfaceMtuTestFixture, singleInterface) { - auto cmd = CmdConfigInterfaceMtu(); - utils::InterfaceList interfaces({"eth1/1/1"}); - MtuValue mtuValue({"1500"}); - - auto result = cmd.queryClient(localhost(), interfaces, mtuValue); - - EXPECT_THAT(result, HasSubstr("Successfully set MTU")); - EXPECT_THAT(result, HasSubstr("eth1/1/1")); - - // Verify the MTU was updated - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& intfs = *switchConfig.interfaces(); - for (const auto& intf : intfs) { - if (*intf.name() == "eth1/1/1") { - EXPECT_EQ(*intf.mtu(), 1500); - } - } -} - -// Test setting MTU on two valid interfaces at once -TEST_F(CmdConfigInterfaceMtuTestFixture, twoValidInterfacesAtOnce) { - auto cmd = CmdConfigInterfaceMtu(); - utils::InterfaceList interfaces({"eth1/1/1", "eth1/2/1"}); - MtuValue mtuValue({"9000"}); - - auto result = cmd.queryClient(localhost(), interfaces, mtuValue); - - EXPECT_THAT(result, HasSubstr("Successfully set MTU")); - EXPECT_THAT(result, HasSubstr("eth1/1/1")); - EXPECT_THAT(result, HasSubstr("eth1/2/1")); - - // Verify both MTUs were updated - auto& session = ConfigSession::getInstance(); - auto& config = session.getAgentConfig(); - auto& switchConfig = *config.sw(); - auto& intfs = *switchConfig.interfaces(); - for (const auto& intf : intfs) { - if (*intf.name() == "eth1/1/1" || *intf.name() == "eth1/2/1") { - EXPECT_EQ(*intf.mtu(), 9000); - } - } -} - -// Test MTU boundary & edge cases validation -TEST_F(CmdConfigInterfaceMtuTestFixture, mtuBoundaryValidation) { - auto cmd = CmdConfigInterfaceMtu(); - utils::InterfaceList interfaces({"eth1/1/1"}); - - // Valid: kMtuMin and kMtuMax should succeed - EXPECT_THAT( - cmd.queryClient( - localhost(), interfaces, MtuValue({std::to_string(utils::kMtuMin)})), - HasSubstr("Successfully set MTU")); - EXPECT_THAT( - cmd.queryClient( - localhost(), interfaces, MtuValue({std::to_string(utils::kMtuMax)})), - HasSubstr("Successfully set MTU")); - - // Invalid: kMtuMin - 1 and kMtuMax + 1 should throw - EXPECT_THROW( - MtuValue({std::to_string(utils::kMtuMin - 1)}), std::invalid_argument); - EXPECT_THROW( - MtuValue({std::to_string(utils::kMtuMax + 1)}), std::invalid_argument); - - // Test that non-numeric MTU value throws - EXPECT_THROW(MtuValue({"abc"}), std::invalid_argument); - - // Test that empty MTU value throws - EXPECT_THROW(MtuValue({}), std::invalid_argument); -} - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp new file mode 100644 index 0000000000000..cfd8f7b7f669e --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp @@ -0,0 +1,247 @@ +/* + * 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 +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigInterfaceSwitchportAccessVlanTestFixture + : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss_switchport_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + + // Create test directories + fs::create_directories(testHomeDir_); + // System config dir is the Git repository root + systemConfigDir_ = testEtcDir_ / "coop"; + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + fs::create_directories(systemConfigDir_ / "cli"); + + // Set environment variables + setenv("HOME", testHomeDir_.c_str(), 1); + setenv("USER", "testuser", 1); + + // Initialize Git repository + Git git(systemConfigDir_.string()); + git.init(); + + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000, + "ingressVlan": 1 + }, + { + "logicalID": 2, + "name": "eth1/2/1", + "state": 2, + "speed": 100000, + "ingressVlan": 1 + } + ] + } +})"); + + // Create symlink at agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Create initial commit + git.commit({"cli/agent.conf"}, "Initial commit"); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestConfig(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; +}; + +// Tests for VlanIdValue validation + +TEST_F(CmdConfigInterfaceSwitchportAccessVlanTestFixture, vlanIdValidMin) { + VlanIdValue vlanId({"1"}); + EXPECT_EQ(vlanId.getVlanId(), 1); +} + +TEST_F(CmdConfigInterfaceSwitchportAccessVlanTestFixture, vlanIdValidMax) { + VlanIdValue vlanId({"4094"}); + EXPECT_EQ(vlanId.getVlanId(), 4094); +} + +TEST_F(CmdConfigInterfaceSwitchportAccessVlanTestFixture, vlanIdValidMid) { + VlanIdValue vlanId({"100"}); + EXPECT_EQ(vlanId.getVlanId(), 100); +} + +TEST_F(CmdConfigInterfaceSwitchportAccessVlanTestFixture, vlanIdZeroInvalid) { + EXPECT_THROW(VlanIdValue({"0"}), std::invalid_argument); +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdTooHighInvalid) { + EXPECT_THROW(VlanIdValue({"4095"}), std::invalid_argument); +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdNegativeInvalid) { + EXPECT_THROW(VlanIdValue({"-1"}), std::invalid_argument); +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdNonNumericInvalid) { + EXPECT_THROW(VlanIdValue({"abc"}), std::invalid_argument); +} + +TEST_F(CmdConfigInterfaceSwitchportAccessVlanTestFixture, vlanIdEmptyInvalid) { + EXPECT_THROW(VlanIdValue({}), std::invalid_argument); +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdMultipleValuesInvalid) { + EXPECT_THROW(VlanIdValue({"100", "200"}), std::invalid_argument); +} + +// Test error message format +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdOutOfRangeErrorMessage) { + try { + VlanIdValue({"9999"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("VLAN ID must be between 1 and 4094")); + EXPECT_THAT(errorMsg, HasSubstr("9999")); + } +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + vlanIdNonNumericErrorMessage) { + try { + VlanIdValue({"notanumber"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("Invalid VLAN ID")); + EXPECT_THAT(errorMsg, HasSubstr("notanumber")); + } +} + +// Tests for queryClient + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + queryClientSetsIngressVlanMultiplePorts) { + fs::create_directories(sessionConfigDir_); + + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + + auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); + VlanIdValue vlanId({"2001"}); + + utils::InterfacesConfig interfaceConfig({"eth1/1/1", "eth1/2/1"}); + + auto result = cmd.queryClient(localhost(), interfaceConfig, vlanId); + + EXPECT_THAT(result, HasSubstr("Successfully set access VLAN")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("eth1/2/1")); + EXPECT_THAT(result, HasSubstr("2001")); + + // Verify the ingressVlan was updated for both ports + auto& session = ConfigSession::getInstance(); + auto& config = session.getAgentConfig(); + auto& switchConfig = *config.sw(); + auto& ports = *switchConfig.ports(); + for (const auto& port : ports) { + if (*port.name() == "eth1/1/1" || *port.name() == "eth1/2/1") { + EXPECT_EQ(*port.ingressVlan(), 2001); + } + } +} + +TEST_F( + CmdConfigInterfaceSwitchportAccessVlanTestFixture, + queryClientThrowsOnEmptyInterfaceList) { + fs::create_directories(sessionConfigDir_); + + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + + // InterfacesConfig with empty input throws during construction + EXPECT_THROW(utils::InterfacesConfig({}), std::invalid_argument); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceTest.cpp new file mode 100644 index 0000000000000..f4e720183adb7 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceTest.cpp @@ -0,0 +1,481 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigInterfaceTestFixture : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss_interface_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + + // Create test directories + fs::create_directories(testHomeDir_); + systemConfigDir_ = testEtcDir_ / "coop"; + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + fs::create_directories(systemConfigDir_ / "cli"); + + // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) + setenv("HOME", testHomeDir_.c_str(), 1); + // NOLINTNEXTLINE(concurrency-mt-unsafe,misc-include-cleaner) + setenv("USER", "testuser", 1); + + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000, + "description": "original description of eth1/1/1" + }, + { + "logicalID": 2, + "name": "eth1/2/1", + "state": 2, + "speed": 100000, + "description": "original description of eth1/2/1" + } + ], + "vlanPorts": [ + { + "vlanID": 1, + "logicalPort": 1 + }, + { + "vlanID": 2, + "logicalPort": 2 + } + ], + "interfaces": [ + { + "intfID": 1, + "routerID": 0, + "vlanID": 1, + "name": "eth1/1/1", + "mtu": 1500 + }, + { + "intfID": 2, + "routerID": 0, + "vlanID": 2, + "name": "eth1/2/1", + "mtu": 1500 + } + ] + } +})"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); + + // Initialize the ConfigSession singleton for all tests + fs::create_directories(sessionConfigDir_); + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestConfig(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; +}; + +// ============================================================================ +// InterfacesConfig Validation Tests +// ============================================================================ + +// Test valid config with port + description +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigValidPortAndDescription) { + utils::InterfacesConfig config({"eth1/1/1", "description", "My port"}); + EXPECT_EQ(config.getInterfaces().size(), 1); + EXPECT_TRUE(config.hasAttributes()); + ASSERT_EQ(config.getAttributes().size(), 1); + EXPECT_EQ(config.getAttributes()[0].first, "description"); + EXPECT_EQ(config.getAttributes()[0].second, "My port"); +} + +// Test valid config with port + mtu +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigValidPortAndMtu) { + utils::InterfacesConfig config({"eth1/1/1", "mtu", "9000"}); + EXPECT_EQ(config.getInterfaces().size(), 1); + EXPECT_TRUE(config.hasAttributes()); + ASSERT_EQ(config.getAttributes().size(), 1); + EXPECT_EQ(config.getAttributes()[0].first, "mtu"); + EXPECT_EQ(config.getAttributes()[0].second, "9000"); +} + +// Test valid config with port + both attributes +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigValidPortAndBothAttrs) { + utils::InterfacesConfig config( + {"eth1/1/1", "description", "My port", "mtu", "9000"}); + EXPECT_EQ(config.getInterfaces().size(), 1); + EXPECT_TRUE(config.hasAttributes()); + ASSERT_EQ(config.getAttributes().size(), 2); + EXPECT_EQ(config.getAttributes()[0].first, "description"); + EXPECT_EQ(config.getAttributes()[0].second, "My port"); + EXPECT_EQ(config.getAttributes()[1].first, "mtu"); + EXPECT_EQ(config.getAttributes()[1].second, "9000"); +} + +// Test valid config with multiple ports + attributes +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigMultiplePorts) { + utils::InterfacesConfig config( + {"eth1/1/1", "eth1/2/1", "description", "Uplink ports"}); + EXPECT_EQ(config.getInterfaces().size(), 2); + EXPECT_TRUE(config.hasAttributes()); + ASSERT_EQ(config.getAttributes().size(), 1); + EXPECT_EQ(config.getAttributes()[0].first, "description"); + EXPECT_EQ(config.getAttributes()[0].second, "Uplink ports"); +} + +// Test port only (no attributes) - for subcommand pass-through +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigPortOnly) { + utils::InterfacesConfig config({"eth1/1/1"}); + EXPECT_EQ(config.getInterfaces().size(), 1); + EXPECT_FALSE(config.hasAttributes()); + EXPECT_TRUE(config.getAttributes().empty()); +} + +// Test case-insensitive attribute names +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigCaseInsensitiveAttrs) { + utils::InterfacesConfig config( + {"eth1/1/1", "DESCRIPTION", "Test", "MTU", "9000"}); + EXPECT_TRUE(config.hasAttributes()); + ASSERT_EQ(config.getAttributes().size(), 2); + // Attributes should be normalized to lowercase + EXPECT_EQ(config.getAttributes()[0].first, "description"); + EXPECT_EQ(config.getAttributes()[1].first, "mtu"); +} + +// Test empty input throws +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigEmptyThrows) { + EXPECT_THROW(utils::InterfacesConfig({}), std::invalid_argument); +} + +// Test first token is attribute (no port name) +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigNoPortNameThrows) { + try { + utils::InterfacesConfig config({"description", "My port"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("No interface name provided")); + EXPECT_THAT(e.what(), HasSubstr("description")); + } +} + +// Test missing value for attribute +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigMissingValueThrows) { + try { + utils::InterfacesConfig config({"eth1/1/1", "description"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("Missing value for attribute")); + EXPECT_THAT(e.what(), HasSubstr("description")); + } +} + +// Test value is actually another attribute (forgot value) +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigValueIsAttributeThrows) { + try { + utils::InterfacesConfig config({"eth1/1/1", "description", "mtu"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("Missing value for attribute")); + EXPECT_THAT(e.what(), HasSubstr("description")); + EXPECT_THAT(e.what(), HasSubstr("mtu")); + } +} + +// Test unknown attribute name - unknown attribute must appear AFTER a known +// attribute to trigger the error. Otherwise, unknown tokens are treated as +// port names and fail during port resolution. +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigUnknownAttributeThrows) { + try { + // "speed" comes after "description", so it's recognized as an attribute + utils::InterfacesConfig config( + {"eth1/1/1", "description", "Test", "speed", "100000"}); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("Unknown attribute")); + EXPECT_THAT(e.what(), HasSubstr("speed")); + } +} + +// Test non-existent port throws +TEST_F(CmdConfigInterfaceTestFixture, interfaceConfigNonExistentPortThrows) { + EXPECT_THROW( + utils::InterfacesConfig({"eth1/99/1", "description", "Test"}), + std::invalid_argument); +} + +// ============================================================================ +// CmdConfigInterface::queryClient Tests +// ============================================================================ + +// Test setting description on a single interface +TEST_F(CmdConfigInterfaceTestFixture, queryClientSetsDescription) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config( + {"eth1/1/1", "description", "New description"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, HasSubstr("Successfully configured")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("description")); + + // Verify the description was updated + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + auto& ports = *switchConfig.ports(); + for (const auto& port : ports) { + if (*port.name() == "eth1/1/1") { + EXPECT_EQ(*port.description(), "New description"); + } else { + // Other ports should be unchanged + EXPECT_EQ(*port.description(), "original description of " + *port.name()); + } + } +} + +// Test setting MTU on a single interface +TEST_F(CmdConfigInterfaceTestFixture, queryClientSetsMtu) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config({"eth1/1/1", "mtu", "9000"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, HasSubstr("Successfully configured")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("mtu=9000")); + + // Verify the MTU was updated + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + auto& intfs = *switchConfig.interfaces(); + for (const auto& intf : intfs) { + if (*intf.name() == "eth1/1/1") { + EXPECT_EQ(*intf.mtu(), 9000); + } else { + // Other interfaces should be unchanged + EXPECT_EQ(*intf.mtu(), 1500); + } + } +} + +// Test setting both description and MTU +TEST_F(CmdConfigInterfaceTestFixture, queryClientSetsBothAttributes) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config( + {"eth1/1/1", "description", "Updated port", "mtu", "9000"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, HasSubstr("Successfully configured")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("description")); + EXPECT_THAT(result, HasSubstr("mtu=9000")); + + // Verify both were updated + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + auto& ports = *switchConfig.ports(); + for (const auto& port : ports) { + if (*port.name() == "eth1/1/1") { + EXPECT_EQ(*port.description(), "Updated port"); + } + } + + auto& intfs = *switchConfig.interfaces(); + for (const auto& intf : intfs) { + if (*intf.name() == "eth1/1/1") { + EXPECT_EQ(*intf.mtu(), 9000); + } + } +} + +// Test setting attributes on multiple interfaces +TEST_F(CmdConfigInterfaceTestFixture, queryClientMultipleInterfaces) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config( + {"eth1/1/1", "eth1/2/1", "description", "Shared description"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, HasSubstr("Successfully configured")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("eth1/2/1")); + + // Verify both descriptions were updated + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + auto& ports = *switchConfig.ports(); + for (const auto& port : ports) { + EXPECT_EQ(*port.description(), "Shared description"); + } +} + +// Test no attributes throws (pass-through case) +TEST_F(CmdConfigInterfaceTestFixture, queryClientNoAttributesThrows) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config({"eth1/1/1"}); + + EXPECT_THROW(cmd.queryClient(localhost(), config), std::runtime_error); +} + +// Test invalid MTU value (non-numeric) +TEST_F(CmdConfigInterfaceTestFixture, queryClientInvalidMtuNonNumeric) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config({"eth1/1/1", "mtu", "abc"}); + + try { + cmd.queryClient(localhost(), config); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("Invalid MTU value")); + EXPECT_THAT(e.what(), HasSubstr("abc")); + } +} + +// Test MTU out of range (too low) +TEST_F(CmdConfigInterfaceTestFixture, queryClientMtuTooLow) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config( + {"eth1/1/1", "mtu", std::to_string(utils::kMtuMin - 1)}); + + try { + cmd.queryClient(localhost(), config); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("out of range")); + } +} + +// Test MTU out of range (too high) +TEST_F(CmdConfigInterfaceTestFixture, queryClientMtuTooHigh) { + auto cmd = CmdConfigInterface(); + utils::InterfacesConfig config( + {"eth1/1/1", "mtu", std::to_string(utils::kMtuMax + 1)}); + + try { + cmd.queryClient(localhost(), config); + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + EXPECT_THAT(e.what(), HasSubstr("out of range")); + } +} + +// Test MTU boundary values (valid) +TEST_F(CmdConfigInterfaceTestFixture, queryClientMtuBoundaryValid) { + auto cmd = CmdConfigInterface(); + + // Test minimum MTU + utils::InterfacesConfig configMin( + {"eth1/1/1", "mtu", std::to_string(utils::kMtuMin)}); + EXPECT_THAT( + cmd.queryClient(localhost(), configMin), + HasSubstr("Successfully configured")); + + // Test maximum MTU + utils::InterfacesConfig configMax( + {"eth1/1/1", "mtu", std::to_string(utils::kMtuMax)}); + EXPECT_THAT( + cmd.queryClient(localhost(), configMax), + HasSubstr("Successfully configured")); +} + +// Test printOutput +TEST_F(CmdConfigInterfaceTestFixture, printOutput) { + auto cmd = CmdConfigInterface(); + std::string successMessage = + "Successfully configured interface(s) eth1/1/1: description=\"Test\""; + + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + cmd.printOutput(successMessage); + std::cout.rdbuf(old); + + EXPECT_EQ(buffer.str(), successMessage + "\n"); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigL2LearningModeTest.cpp b/fboss/cli/fboss2/test/CmdConfigL2LearningModeTest.cpp new file mode 100644 index 0000000000000..368842775cc93 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigL2LearningModeTest.cpp @@ -0,0 +1,209 @@ +/* + * 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 +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigL2LearningModeTestFixture : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss_l2_learning_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + + // Create test directories + fs::create_directories(testHomeDir_); + systemConfigDir_ = testEtcDir_ / "coop"; + fs::create_directories(systemConfigDir_ / "cli"); + + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("HOME", testHomeDir_.c_str(), 1); + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("USER", "testuser", 1); + + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ + "sw": { + "switchSettings": { + "l2LearningMode": 0 + } + } +})"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); + + // Initialize the ConfigSession singleton for all tests + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestConfig(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; +}; + +// ============================================================================== +// L2LearningModeArg Validation Tests +// ============================================================================== + +TEST_F(CmdConfigL2LearningModeTestFixture, learningModeArgValidation) { + // Test valid modes (lowercase) + EXPECT_EQ( + L2LearningModeArg({"hardware"}).getL2LearningMode(), + cfg::L2LearningMode::HARDWARE); + EXPECT_EQ( + L2LearningModeArg({"software"}).getL2LearningMode(), + cfg::L2LearningMode::SOFTWARE); + EXPECT_EQ( + L2LearningModeArg({"disabled"}).getL2LearningMode(), + cfg::L2LearningMode::DISABLED); + + // Test valid modes (uppercase) + EXPECT_EQ( + L2LearningModeArg({"HARDWARE"}).getL2LearningMode(), + cfg::L2LearningMode::HARDWARE); + EXPECT_EQ( + L2LearningModeArg({"SOFTWARE"}).getL2LearningMode(), + cfg::L2LearningMode::SOFTWARE); + EXPECT_EQ( + L2LearningModeArg({"DISABLED"}).getL2LearningMode(), + cfg::L2LearningMode::DISABLED); + + // Test valid modes (mixed case) + EXPECT_EQ( + L2LearningModeArg({"HaRdWaRe"}).getL2LearningMode(), + cfg::L2LearningMode::HARDWARE); + + // Test invalid cases + EXPECT_THROW(L2LearningModeArg({}), std::invalid_argument); + EXPECT_THROW(L2LearningModeArg({"hardware", "extra"}), std::invalid_argument); + EXPECT_THROW(L2LearningModeArg({"invalid"}), std::invalid_argument); +} + +// ============================================================================== +// Command Execution Tests +// ============================================================================== + +TEST_F(CmdConfigL2LearningModeTestFixture, setLearningModeToSoftware) { + CmdConfigL2LearningMode cmd; + HostInfo hostInfo("testhost"); + L2LearningModeArg modeArg({"software"}); + + auto result = cmd.queryClient(hostInfo, modeArg); + EXPECT_THAT(result, HasSubstr("software")); + + // Verify the config was updated + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto mode = *config.sw()->switchSettings()->l2LearningMode(); + EXPECT_EQ(mode, cfg::L2LearningMode::SOFTWARE); +} + +TEST_F(CmdConfigL2LearningModeTestFixture, setLearningModeToHardware) { + // First set to software, then back to hardware + CmdConfigL2LearningMode cmd; + HostInfo hostInfo("testhost"); + + L2LearningModeArg softwareArg({"software"}); + cmd.queryClient(hostInfo, softwareArg); + + L2LearningModeArg hardwareArg({"hardware"}); + auto result = cmd.queryClient(hostInfo, hardwareArg); + EXPECT_THAT(result, HasSubstr("hardware")); + + // Verify the config was updated + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto mode = *config.sw()->switchSettings()->l2LearningMode(); + EXPECT_EQ(mode, cfg::L2LearningMode::HARDWARE); +} + +TEST_F(CmdConfigL2LearningModeTestFixture, setLearningModeToDisabled) { + CmdConfigL2LearningMode cmd; + HostInfo hostInfo("testhost"); + L2LearningModeArg modeArg({"disabled"}); + + auto result = cmd.queryClient(hostInfo, modeArg); + EXPECT_THAT(result, HasSubstr("disabled")); + + // Verify the config was updated + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto mode = *config.sw()->switchSettings()->l2LearningMode(); + EXPECT_EQ(mode, cfg::L2LearningMode::DISABLED); +} + +TEST_F(CmdConfigL2LearningModeTestFixture, setLearningModeAlreadySet) { + CmdConfigL2LearningMode cmd; + HostInfo hostInfo("testhost"); + + // Default is HARDWARE (mode=0) + L2LearningModeArg hardwareArg({"hardware"}); + auto result = cmd.queryClient(hostInfo, hardwareArg); + EXPECT_THAT(result, HasSubstr("already")); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp b/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp index b5dc507888064..458bd487f5a45 100644 --- a/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp @@ -8,16 +8,17 @@ * */ -#include +#include #include #include #include #include #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h" +#include "fboss/cli/fboss2/session/Git.h" #include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" #include "fboss/cli/fboss2/test/TestableConfigSession.h" -#include "fboss/cli/fboss2/utils/PortMap.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) namespace fs = std::filesystem; @@ -45,16 +46,19 @@ class CmdConfigQosBufferPoolTestFixture : public CmdHandlerTestBase { // Create test directories fs::create_directories(testHomeDir_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); + systemConfigDir_ = testEtcDir_ / "coop"; + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + fs::create_directories(systemConfigDir_ / "cli"); // Set environment variables + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests setenv("HOME", testHomeDir_.c_str(), 1); + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests setenv("USER", "testuser", 1); - // Create a test system config file - fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; - createTestConfig(initialRevision, R"({ + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ "sw": { "ports": [ { @@ -67,13 +71,13 @@ class CmdConfigQosBufferPoolTestFixture : public CmdHandlerTestBase { } })"); - // Create symlink - systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; - fs::create_symlink(initialRevision, systemConfigPath_); + // Create symlink at agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); - // Create session config path - sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; - cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({"cli/agent.conf"}, "Initial commit"); } void TearDown() override { @@ -106,9 +110,8 @@ class CmdConfigQosBufferPoolTestFixture : public CmdHandlerTestBase { fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; - fs::path sessionConfigPath_; - fs::path cliConfigDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; }; // Test BufferPoolConfig argument validation @@ -176,11 +179,10 @@ TEST_F(CmdConfigQosBufferPoolTestFixture, bufferPoolConfigGetters) { // Test shared-bytes command creates buffer pool config TEST_F(CmdConfigQosBufferPoolTestFixture, sharedBytesCreatesBufferPool) { + fs::create_directories(sessionConfigDir_); TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigQosBufferPool(); BufferPoolConfig config({"test_pool", "shared-bytes", "50000"}); @@ -203,11 +205,10 @@ TEST_F(CmdConfigQosBufferPoolTestFixture, sharedBytesCreatesBufferPool) { // Test headroom-bytes command creates buffer pool config TEST_F(CmdConfigQosBufferPoolTestFixture, headroomBytesCreatesBufferPool) { + fs::create_directories(sessionConfigDir_); TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigQosBufferPool(); BufferPoolConfig config({"headroom_pool", "headroom-bytes", "10000"}); @@ -232,11 +233,10 @@ TEST_F(CmdConfigQosBufferPoolTestFixture, headroomBytesCreatesBufferPool) { // Test reserved-bytes command creates buffer pool config TEST_F(CmdConfigQosBufferPoolTestFixture, reservedBytesCreatesBufferPool) { + fs::create_directories(sessionConfigDir_); TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigQosBufferPool(); BufferPoolConfig config({"reserved_pool", "reserved-bytes", "20000"}); @@ -261,11 +261,10 @@ TEST_F(CmdConfigQosBufferPoolTestFixture, reservedBytesCreatesBufferPool) { // Test updating an existing buffer pool with multiple attributes TEST_F(CmdConfigQosBufferPoolTestFixture, updateExistingBufferPool) { + fs::create_directories(sessionConfigDir_); TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigQosBufferPool(); diff --git a/fboss/cli/fboss2/test/CmdConfigSessionClearTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionClearTest.cpp new file mode 100644 index 0000000000000..98b7bce8322ab --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigSessionClearTest.cpp @@ -0,0 +1,164 @@ +/* + * 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 +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionClear.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" + +namespace fs = std::filesystem; + +namespace facebook::fboss { + +class ConfigSessionClearTestFixture : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directory for each test to avoid conflicts when + // running tests in parallel + auto tempBase = fs::temp_directory_path(); + auto uniquePath = + boost::filesystem::unique_path("fboss_clear_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + + // Clean up any previous test artifacts (shouldn't exist with unique names) + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + + // Create test directory + fs::create_directories(testHomeDir_); + + // Set environment variable so ConfigSession uses our test directory + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("HOME", testHomeDir_.c_str(), 1); + } + + void TearDown() override { + // Clean up test directory + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestFile(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; +}; + +TEST_F(ConfigSessionClearTestFixture, clearExistingSession) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + + // Create session files manually + fs::create_directories(sessionDir); + createTestFile(sessionConfig, R"({"sw": {"ports": []}})"); + createTestFile(metadataFile, R"({"action":{"WEDGE_AGENT":"HITLESS"}})"); + + // Verify session files exist + EXPECT_TRUE(fs::exists(sessionConfig)); + EXPECT_TRUE(fs::exists(metadataFile)); + + // Create the clear command and execute it + CmdConfigSessionClear cmd; + auto result = cmd.queryClient(localhost()); + + // Verify the result message + EXPECT_EQ(result, "Config session cleared successfully."); + + // Verify session files were removed + EXPECT_FALSE(fs::exists(sessionConfig)); + EXPECT_FALSE(fs::exists(metadataFile)); + + // Verify session directory still exists (we only remove files, not the dir) + EXPECT_TRUE(fs::exists(sessionDir)); +} + +TEST_F(ConfigSessionClearTestFixture, clearWhenNoSessionExists) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + + // Ensure no session files exist + EXPECT_FALSE(fs::exists(sessionConfig)); + EXPECT_FALSE(fs::exists(metadataFile)); + + // Create the clear command and execute it + CmdConfigSessionClear cmd; + auto result = cmd.queryClient(localhost()); + + // Verify the result message indicates nothing to clear + EXPECT_EQ(result, "No config session exists. Nothing to clear."); +} + +TEST_F(ConfigSessionClearTestFixture, clearOnlyConfigFile) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + + // Create only the config file (not metadata) + fs::create_directories(sessionDir); + createTestFile(sessionConfig, R"({"sw": {"ports": []}})"); + + // Verify only config file exists + EXPECT_TRUE(fs::exists(sessionConfig)); + EXPECT_FALSE(fs::exists(metadataFile)); + + // Create the clear command and execute it + CmdConfigSessionClear cmd; + auto result = cmd.queryClient(localhost()); + + // Verify the result message + EXPECT_EQ(result, "Config session cleared successfully."); + + // Verify config file was removed + EXPECT_FALSE(fs::exists(sessionConfig)); +} + +TEST_F(ConfigSessionClearTestFixture, clearOnlyMetadataFile) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + + // Create only the metadata file (not config) + fs::create_directories(sessionDir); + createTestFile(metadataFile, R"({"action":{"WEDGE_AGENT":"HITLESS"}})"); + + // Verify only metadata file exists + EXPECT_FALSE(fs::exists(sessionConfig)); + EXPECT_TRUE(fs::exists(metadataFile)); + + // Create the clear command and execute it + CmdConfigSessionClear cmd; + auto result = cmd.queryClient(localhost()); + + // Verify the result message + EXPECT_EQ(result, "Config session cleared successfully."); + + // Verify metadata file was removed + EXPECT_FALSE(fs::exists(metadataFile)); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp index 8488e18815854..014cd4eab32ec 100644 --- a/fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp @@ -1,19 +1,17 @@ // (c) Facebook, Inc. and its affiliates. Confidential and proprietary. -#include -#include +#include #include +#include +#include #include #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" -#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" #include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" #include "fboss/cli/fboss2/test/TestableConfigSession.h" #include "fboss/cli/fboss2/utils/CmdUtils.h" -#include "fboss/cli/fboss2/utils/PortMap.h" - -#include -#include +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) namespace fs = std::filesystem; @@ -25,9 +23,8 @@ class CmdConfigSessionDiffTestFixture : public CmdHandlerTestBase { public: fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; - fs::path sessionConfigPath_; - fs::path cliConfigDir_; + fs::path systemConfigDir_; // /etc/coop (git repo root) + fs::path sessionConfigDir_; // ~/.fboss2 void SetUp() override { CmdHandlerTestBase::SetUp(); @@ -50,16 +47,19 @@ class CmdConfigSessionDiffTestFixture : public CmdHandlerTestBase { fs::create_directories(testEtcDir_); // Set up paths - systemConfigPath_ = testEtcDir_ / "agent.conf"; - sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; - cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + // Structure: systemConfigDir_ = /etc/coop (git repo root) + // - agent.conf (symlink -> cli/agent.conf) + // - cli/agent.conf (actual config file) + systemConfigDir_ = testEtcDir_ / "coop"; + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Create CLI config directory - fs::create_directories(cliConfigDir_); + fs::create_directories(systemConfigDir_ / "cli"); - // Create a default system config + // Create the actual config file at cli/agent.conf createTestConfig( - systemConfigPath_, + cliConfigPath, R"({ "sw": { "ports": [ @@ -72,6 +72,14 @@ class CmdConfigSessionDiffTestFixture : public CmdHandlerTestBase { ] } })"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); } void TearDown() override { @@ -99,11 +107,13 @@ class CmdConfigSessionDiffTestFixture : public CmdHandlerTestBase { } void initializeTestSession() { + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + // Ensure system config exists before initializing session // (ConfigSession constructor calls initializeSession which will copy it) - if (!fs::exists(systemConfigPath_)) { + if (!fs::exists(cliConfigPath)) { createTestConfig( - systemConfigPath_, + cliConfigPath, R"({ "sw": { "ports": [ @@ -118,26 +128,25 @@ class CmdConfigSessionDiffTestFixture : public CmdHandlerTestBase { })"); } - // Ensure the parent directory of session config exists - // (initializeSession will try to copy system config to session config) - fs::create_directories(sessionConfigPath_.parent_path()); + // Ensure the session config directory exists + fs::create_directories(sessionConfigDir_); // Initialize ConfigSession singleton with test paths // The constructor will automatically call initializeSession() TestableConfigSession::setInstance( std::make_unique( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string())); + sessionConfigDir_.string(), systemConfigDir_.string())); } }; TEST_F(CmdConfigSessionDiffTestFixture, diffNoSession) { + fs::path sessionConfigPath = sessionConfigDir_ / "agent.conf"; + // Initialize the session (which creates the session config file) initializeTestSession(); // Then delete the session config file to simulate "no session" case - fs::remove(sessionConfigPath_); + fs::remove(sessionConfigPath); auto cmd = CmdConfigSessionDiff(); utils::RevisionList emptyRevisions(std::vector{}); @@ -165,12 +174,14 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffIdenticalConfigs) { } TEST_F(CmdConfigSessionDiffTestFixture, diffDifferentConfigs) { + fs::path sessionConfigPath = sessionConfigDir_ / "agent.conf"; + initializeTestSession(); // Create session directory and modify the config - fs::create_directories(sessionConfigPath_.parent_path()); + fs::create_directories(sessionConfigDir_); createTestConfig( - sessionConfigPath_, + sessionConfigPath, R"({ "sw": { "ports": [ @@ -196,11 +207,12 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffDifferentConfigs) { } TEST_F(CmdConfigSessionDiffTestFixture, diffSessionVsRevision) { - initializeTestSession(); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + fs::path sessionConfigPath = sessionConfigDir_ / "agent.conf"; - // Create a revision file + // Create a commit with state: 1 createTestConfig( - cliConfigDir_ / "agent-r1.conf", + cliConfigPath, R"({ "sw": { "ports": [ @@ -213,10 +225,15 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffSessionVsRevision) { } })"); - // Create session with different content - fs::create_directories(sessionConfigPath_.parent_path()); + Git git(systemConfigDir_.string()); + std::string commitSha = git.commit({cliConfigPath.string()}, "State 1"); + + initializeTestSession(); + + // Create session with different content (state: 2) + fs::create_directories(sessionConfigDir_); createTestConfig( - sessionConfigPath_, + sessionConfigPath, R"({ "sw": { "ports": [ @@ -230,21 +247,23 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffSessionVsRevision) { })"); auto cmd = CmdConfigSessionDiff(); - utils::RevisionList revisions(std::vector{"r1"}); + utils::RevisionList revisions(std::vector{commitSha}); auto result = cmd.queryClient(localhost(), revisions); - // Should show a diff between r1 and session (state changed from 1 to 2) + // Should show a diff between the commit and session (state changed from 1 to + // 2) EXPECT_NE(result.find("- \"state\": 1"), std::string::npos); EXPECT_NE(result.find("+ \"state\": 2"), std::string::npos); } TEST_F(CmdConfigSessionDiffTestFixture, diffTwoRevisions) { - initializeTestSession(); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + Git git(systemConfigDir_.string()); - // Create two different revision files + // Create first commit with state: 2 createTestConfig( - cliConfigDir_ / "agent-r1.conf", + cliConfigPath, R"({ "sw": { "ports": [ @@ -256,9 +275,11 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffTwoRevisions) { ] } })"); + std::string commit1 = git.commit({cliConfigPath.string()}, "Commit 1"); + // Create second commit with description added createTestConfig( - cliConfigDir_ / "agent-r2.conf", + cliConfigPath, R"({ "sw": { "ports": [ @@ -271,9 +292,12 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffTwoRevisions) { ] } })"); + std::string commit2 = git.commit({cliConfigPath.string()}, "Commit 2"); + + initializeTestSession(); auto cmd = CmdConfigSessionDiff(); - utils::RevisionList revisions(std::vector{"r1", "r2"}); + utils::RevisionList revisions(std::vector{commit1, commit2}); auto result = cmd.queryClient(localhost(), revisions); @@ -283,11 +307,12 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffTwoRevisions) { } TEST_F(CmdConfigSessionDiffTestFixture, diffWithCurrentKeyword) { - initializeTestSession(); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + Git git(systemConfigDir_.string()); - // Create a revision file + // Create a commit with state: 1 createTestConfig( - cliConfigDir_ / "agent-r1.conf", + cliConfigPath, R"({ "sw": { "ports": [ @@ -299,14 +324,34 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffWithCurrentKeyword) { ] } })"); + std::string commit1 = git.commit({cliConfigPath.string()}, "State 1"); + + // Update system config to state: 2 with speed (this is "current") + createTestConfig( + cliConfigPath, + R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000 + } + ] + } +})"); + git.commit({cliConfigPath.string()}, "Current state"); + + initializeTestSession(); auto cmd = CmdConfigSessionDiff(); - utils::RevisionList revisions(std::vector{"r1", "current"}); + utils::RevisionList revisions(std::vector{commit1, "current"}); auto result = cmd.queryClient(localhost(), revisions); - // Should show a diff between r1 and current system config (state changed from - // 1 to 2, speed added) + // Should show a diff between commit1 and current system config (state changed + // from 1 to 2, speed added) EXPECT_NE(result.find("- \"state\": 1"), std::string::npos); EXPECT_NE(result.find("+ \"state\": 2"), std::string::npos); EXPECT_NE(result.find("+ \"speed\": 100000"), std::string::npos); @@ -316,17 +361,25 @@ TEST_F(CmdConfigSessionDiffTestFixture, diffNonexistentRevision) { initializeTestSession(); auto cmd = CmdConfigSessionDiff(); - utils::RevisionList revisions(std::vector{"r999"}); + // Use a fake SHA that doesn't exist + utils::RevisionList revisions( + std::vector{"0000000000000000000000000000000000000000"}); // Should throw an exception for nonexistent revision - EXPECT_THROW(cmd.queryClient(localhost(), revisions), std::invalid_argument); + // Git throws runtime_error when the commit doesn't exist + EXPECT_THROW(cmd.queryClient(localhost(), revisions), std::runtime_error); } TEST_F(CmdConfigSessionDiffTestFixture, diffTooManyArguments) { initializeTestSession(); auto cmd = CmdConfigSessionDiff(); - utils::RevisionList revisions(std::vector{"r1", "r2", "r3"}); + // Use fake SHAs for the test + utils::RevisionList revisions( + std::vector{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccc"}); // Should throw an exception for too many arguments EXPECT_THROW(cmd.queryClient(localhost(), revisions), std::invalid_argument); diff --git a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp index 9f04b40cf2bf8..85cebcb2e6cce 100644 --- a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp @@ -8,19 +8,24 @@ * */ -#include -#include +#include +#include +#include #include #include -#include #include #include -#include +#include +#include +#include +#include +#include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" #include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" #include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" #include "fboss/cli/fboss2/test/TestableConfigSession.h" -#include "fboss/cli/fboss2/utils/PortMap.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) namespace fs = std::filesystem; @@ -49,18 +54,20 @@ class ConfigSessionTestFixture : public CmdHandlerTestBase { } // Create test directories + // Structure: systemConfigDir_ = /etc/coop (git repo root) + // - agent.conf (symlink -> cli/agent.conf) + // - cli/agent.conf (actual config file) fs::create_directories(testHomeDir_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); + systemConfigDir_ = testEtcDir_ / "coop"; + fs::create_directories(systemConfigDir_ / "cli"); // Set environment variables setenv("HOME", testHomeDir_.c_str(), 1); setenv("USER", "testuser", 1); - // Create a test system config file as agent-r1.conf in the cli directory - // and create a symlink at agent.conf pointing to it - fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; - createTestConfig(initialRevision, R"({ + // Create the actual config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ "sw": { "ports": [ { @@ -68,15 +75,24 @@ class ConfigSessionTestFixture : public CmdHandlerTestBase { "name": "eth1/1/1", "state": 2, "speed": 100000 + }, + { + "logicalID": 2, + "name": "eth1/1/2", + "state": 2, + "speed": 100000 } ] } })"); - // Create symlink at agent.conf pointing to agent-r1.conf - // Use an absolute path for the symlink target so it works in tests - systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; - fs::create_symlink(initialRevision, systemConfigPath_); + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); } void TearDown() override { @@ -117,33 +133,26 @@ class ConfigSessionTestFixture : public CmdHandlerTestBase { fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; + fs::path systemConfigDir_; // /etc/coop (git repo root) }; TEST_F(ConfigSessionTestFixture, sessionInitialization) { // Initially, session directory should not exist fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; EXPECT_FALSE(fs::exists(sessionDir)); // Creating a ConfigSession should create the directory and copy the config - // systemConfigPath_ is already a symlink created in SetUp() - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Verify the directory was created EXPECT_TRUE(fs::exists(sessionDir)); EXPECT_TRUE(session.sessionExists()); EXPECT_TRUE(fs::exists(sessionConfig)); - // Verify content was copied correctly - // Read the actual file that systemConfigPath_ points to - // fs::read_symlink returns a relative path, so we need to resolve it - fs::path symlinkTarget = fs::read_symlink(systemConfigPath_); - fs::path actualConfigPath = systemConfigPath_.parent_path() / symlinkTarget; - std::string systemContent = readFile(actualConfigPath); + // Verify content was copied correctly (reads via symlink) + std::string systemContent = readFile(cliConfigPath); std::string sessionContent = readFile(sessionConfig); EXPECT_EQ(systemContent, sessionContent); } @@ -151,13 +160,10 @@ TEST_F(ConfigSessionTestFixture, sessionInitialization) { TEST_F(ConfigSessionTestFixture, sessionConfigModified) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Create a ConfigSession - // systemConfigPath_ is already a symlink created in SetUp() - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Modify the session config through the ConfigSession API auto& config = session.getAgentConfig(); @@ -168,10 +174,7 @@ TEST_F(ConfigSessionTestFixture, sessionConfigModified) { // Verify session config is modified std::string sessionContent = readFile(sessionConfig); - // fs::read_symlink returns a relative path, so we need to resolve it - fs::path symlinkTarget = fs::read_symlink(systemConfigPath_); - fs::path actualConfigPath = systemConfigPath_.parent_path() / symlinkTarget; - std::string systemContent = readFile(actualConfigPath); + std::string systemContent = readFile(cliConfigPath); EXPECT_NE(sessionContent, systemContent); EXPECT_THAT(sessionContent, ::testing::HasSubstr("Modified port")); } @@ -179,25 +182,22 @@ TEST_F(ConfigSessionTestFixture, sessionConfigModified) { TEST_F(ConfigSessionTestFixture, sessionCommit) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; - - // Verify old symlink exists (created in SetUp) - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ( - fs::read_symlink(systemConfigPath_), cliConfigDir / "agent-r1.conf"); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Setup mock agent server setupMockedAgentServer(); EXPECT_CALL(getMockAgent(), reloadConfig()).Times(2); + std::string firstCommitSha; + std::string secondCommitSha; + // First commit: Create a ConfigSession and commit a change { - // systemConfigPath_ is already a symlink to agent-r1.conf created in - // SetUp() TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + sessionDir.string(), systemConfigDir_.string()); + + // Simulate a CLI command being tracked + session.addCommand("config interface eth1/1/1 description First commit"); // Modify the session config auto& config = session.getAgentConfig(); @@ -213,25 +213,28 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { // Verify session config no longer exists (removed after commit) EXPECT_FALSE(fs::exists(sessionConfig)); - // Verify new revision was created in cli directory - EXPECT_EQ(result.revision, 2); - fs::path targetConfig = cliConfigDir / "agent-r2.conf"; - EXPECT_TRUE(fs::exists(targetConfig)); - EXPECT_THAT(readFile(targetConfig), ::testing::HasSubstr("First commit")); + // Verify commit SHA was returned + EXPECT_FALSE(result.commitSha.empty()); + EXPECT_EQ(result.commitSha.length(), 40); // Full SHA1 is 40 chars + firstCommitSha = result.commitSha; - // Verify symlink was replaced and points to new revision - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); + // Verify metadata file was created alongside the config revision + fs::path targetMetadata = systemConfigDir_ / "cli" / "cli_metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); + + // Verify system config was updated + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("First commit")); } - // Second commit: Create a new session and verify it's based on r2, not r1 + // Second commit: Create a new session and verify it's based on first commit { TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + sessionDir.string(), systemConfigDir_.string()); + + // Simulate a CLI command being tracked + session.addCommand("config interface eth1/1/1 description Second commit"); - // Verify the new session is based on r2 (the latest committed revision) + // Verify the new session is based on the latest committed revision auto& config = session.getAgentConfig(); auto& ports = *config.sw()->ports(); ASSERT_FALSE(ports.empty()); @@ -245,33 +248,69 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { // Commit the second change auto result = session.commit(localhost()); - // Verify new revision was created - EXPECT_EQ(result.revision, 3); - fs::path targetConfig = cliConfigDir / "agent-r3.conf"; - EXPECT_TRUE(fs::exists(targetConfig)); - EXPECT_THAT(readFile(targetConfig), ::testing::HasSubstr("Second commit")); + // Verify new commit SHA was returned + EXPECT_FALSE(result.commitSha.empty()); + EXPECT_NE(result.commitSha, firstCommitSha); + secondCommitSha = result.commitSha; + + // Verify metadata file was created alongside the config revision + fs::path targetMetadata = systemConfigDir_ / "cli" / "cli_metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); + + // Verify system config was updated + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("Second commit")); - // Verify symlink was updated to point to r3 - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); + // Verify Git history has all commits + auto& git = session.getGit(); + auto commits = git.log(cliConfigPath.string()); + EXPECT_EQ(commits.size(), 3); // Initial + 2 commits - // Verify all revisions exist - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r1.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); + // Verify metadata file was also committed to git + auto metadataCommits = git.log(targetMetadata.string()); + EXPECT_EQ(metadataCommits.size(), 2); // 2 commits } } +// Ensure commit() works on a newly initialized session +// This verifies that initializeSession() creates the metadata file +TEST_F(ConfigSessionTestFixture, commitOnNewlyInitializedSession) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path cliConfigDir = systemConfigDir_ / "cli"; + + // Setup mock agent server + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + + // Create a new session + // This tests that metadata file is created during session initialization + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); + + // Verify metadata file was created during session initialization + fs::path metadataPath = sessionDir / "cli_metadata.json"; + EXPECT_TRUE(fs::exists(metadataPath)); + + // Make a change so commit has something to commit + auto& config = session.getAgentConfig(); + auto& ports = *config.sw()->ports(); + ASSERT_FALSE(ports.empty()); + ports[0].description() = "Test change for commit"; + session.saveConfig(); + + // Commit should succeed + auto result = session.commit(localhost()); + EXPECT_FALSE(result.commitSha.empty()); + + // Verify metadata file was copied to CLI config directory + fs::path targetMetadata = cliConfigDir / "cli_metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); +} + TEST_F(ConfigSessionTestFixture, multipleChangesInOneSession) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession - // systemConfigPath_ is already a symlink created in SetUp() - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Make first change auto& config = session.getAgentConfig(); @@ -297,12 +336,9 @@ TEST_F(ConfigSessionTestFixture, sessionPersistsAcrossCommands) { fs::path sessionConfig = sessionDir / "agent.conf"; // Create first ConfigSession and modify config - // systemConfigPath_ is already a symlink created in SetUp() { TestableConfigSession session1( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); auto& config = session1.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -321,9 +357,7 @@ TEST_F(ConfigSessionTestFixture, sessionPersistsAcrossCommands) { // session { TestableConfigSession session2( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); auto& config = session2.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -333,15 +367,13 @@ TEST_F(ConfigSessionTestFixture, sessionPersistsAcrossCommands) { } } -TEST_F(ConfigSessionTestFixture, symlinkRollbackOnFailure) { +TEST_F(ConfigSessionTestFixture, configRollbackOnFailure) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; - // Verify old symlink exists (created in SetUp) - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ( - fs::read_symlink(systemConfigPath_), cliConfigDir / "agent-r1.conf"); + // Save the original config content + std::string originalContent = readFile(cliConfigPath); // Setup mock agent server to fail reloadConfig on first call (the commit), // but succeed on second call (the rollback reload) @@ -351,11 +383,7 @@ TEST_F(ConfigSessionTestFixture, symlinkRollbackOnFailure) { .WillOnce(::testing::Return()); // Create a ConfigSession and try to commit - // systemConfigPath_ is already a symlink to agent-r1.conf created in SetUp() - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); auto& config = session.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -363,43 +391,36 @@ TEST_F(ConfigSessionTestFixture, symlinkRollbackOnFailure) { ports[0].description() = "Failed change"; session.saveConfig(cli::ServiceType::AGENT, cli::ConfigActionLevel::HITLESS); - // Commit should fail and rollback the symlink + // Commit should fail and rollback the config EXPECT_THROW(session.commit(localhost()), std::runtime_error); - // Verify symlink was rolled back to old target - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ( - fs::read_symlink(systemConfigPath_), cliConfigDir / "agent-r1.conf"); + // Verify config was rolled back to original content + std::string currentContent = readFile(cliConfigPath); + EXPECT_EQ(currentContent, originalContent); // Verify session config still exists (not removed on failed commit) EXPECT_TRUE(fs::exists(sessionConfig)); } -TEST_F(ConfigSessionTestFixture, atomicRevisionCreation) { - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; +TEST_F(ConfigSessionTestFixture, concurrentCommits) { + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Setup mock agent server setupMockedAgentServer(); EXPECT_CALL(getMockAgent(), reloadConfig()).Times(2); - // Run two concurrent commits to test atomic revision creation - // Each thread uses a separate session config path (simulating different - // users) Both threads will try to commit at the same time, and the atomic - // file creation (O_CREAT | O_EXCL) should ensure they get different revision - // numbers without conflicts - std::atomic revision1{0}; - std::atomic revision2{0}; + // Run two sequential commits to test Git commit functionality + // Note: Git doesn't handle truly concurrent commits well due to index.lock, + // so we run them sequentially to avoid race conditions. + std::string commitSha1; + std::string commitSha2; - auto commitTask = [&](const std::string& sessionName, - const std::string& description, - std::atomic& rev) { - fs::path sessionDir = testHomeDir_ / sessionName; - fs::path sessionConfig = sessionDir / "agent.conf"; + // First commit + { + fs::path sessionDir = testHomeDir_ / ".fboss2_user1"; TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + sessionDir.string(), systemConfigDir_.string()); auto& config = session.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -408,72 +429,16 @@ TEST_F(ConfigSessionTestFixture, atomicRevisionCreation) { session.saveConfig( cli::ServiceType::AGENT, cli::ConfigActionLevel::HITLESS); - rev = session.commit(localhost()).revision; - }; - - std::thread thread1( - commitTask, ".fboss2_user1", "First commit", std::ref(revision1)); - std::thread thread2( - commitTask, ".fboss2_user2", "Second commit", std::ref(revision2)); - - thread1.join(); - thread2.join(); - - // Both commits should succeed with different revision numbers - EXPECT_NE(revision1.load(), 0); - EXPECT_NE(revision2.load(), 0); - EXPECT_NE(revision1.load(), revision2.load()); - - // Both should be either r2 or r3 (one gets r2, the other gets r3) - EXPECT_TRUE( - (revision1.load() == 2 && revision2.load() == 3) || - (revision1.load() == 3 && revision2.load() == 2)); - - // Both revision files should exist - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); - - // Verify the content of each revision matches what was committed - std::string r2Content = readFile(cliConfigDir / "agent-r2.conf"); - std::string r3Content = readFile(cliConfigDir / "agent-r3.conf"); - EXPECT_TRUE( - (r2Content.find("First commit") != std::string::npos && - r3Content.find("Second commit") != std::string::npos) || - (r2Content.find("Second commit") != std::string::npos && - r3Content.find("First commit") != std::string::npos)); -} - -TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; - - // Setup mock agent server - setupMockedAgentServer(); - EXPECT_CALL(getMockAgent(), reloadConfig()).Times(2); + auto result = session.commit(localhost()); + commitSha1 = result.commitSha; + } - // Test concurrent session creation and commits for the SAME user - // This tests the race conditions in: - // 1. ensureDirectoryExists() - concurrent directory creation - // 2. copySystemConfigToSession() - concurrent session file creation - // 3. saveConfig() - concurrent writes to the same session file - // 4. atomicSymlinkUpdate() - concurrent symlink updates - // - // Note: When two threads share the same session file, they race to modify it. - // The atomic operations ensure no crashes or corruption, but both commits - // might have the same content if one thread's saveConfig() overwrites the - // other's changes. This is expected behavior - the important thing is that - // both commits succeed without crashes. - std::atomic revision1{0}; - std::atomic revision2{0}; - - auto commitTask = [&](const std::string& description, std::atomic& rev) { - // Both threads use the SAME session path - fs::path sessionDir = testHomeDir_ / ".fboss2_shared"; - fs::path sessionConfig = sessionDir / "agent.conf"; + // Second commit + { + fs::path sessionDir = testHomeDir_ / ".fboss2_user2"; TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + sessionDir.string(), systemConfigDir_.string()); auto& config = session.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -482,177 +447,158 @@ TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { session.saveConfig( cli::ServiceType::AGENT, cli::ConfigActionLevel::HITLESS); - rev = session.commit(localhost()).revision; - }; - - std::thread thread1(commitTask, "First commit", std::ref(revision1)); - std::thread thread2(commitTask, "Second commit", std::ref(revision2)); - - thread1.join(); - thread2.join(); - - // Both commits should succeed with different revision numbers - EXPECT_NE(revision1.load(), 0); - EXPECT_NE(revision2.load(), 0); - EXPECT_NE(revision1.load(), revision2.load()); - - // Both should be either r2 or r3 (one gets r2, the other gets r3) - EXPECT_TRUE( - (revision1.load() == 2 && revision2.load() == 3) || - (revision1.load() == 3 && revision2.load() == 2)); + auto result = session.commit(localhost()); + commitSha2 = result.commitSha; + } - // Both revision files should exist - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); + // Both commits should succeed with different commit SHAs + EXPECT_FALSE(commitSha1.empty()); + EXPECT_FALSE(commitSha2.empty()); + EXPECT_NE(commitSha1, commitSha2); - // The history command would list all three revisions with their metadata + // Verify Git history contains both commits + Git git(systemConfigDir_.string()); + auto commits = git.log(cliConfigPath.string()); + EXPECT_GE(commits.size(), 3); // Initial + 2 commits } -TEST_F(ConfigSessionTestFixture, revisionNumberExtraction) { - // Test the revision number extraction logic - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; +TEST_F(ConfigSessionTestFixture, rollbackToSpecificCommit) { + // This test calls the rollback() method with a specific commit SHA + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + fs::path metadataPath = systemConfigDir_ / "cli" / "cli_metadata.json"; - // Create files with various revision numbers - createTestConfig(cliConfigDir / "agent-r1.conf", R"({})"); - createTestConfig(cliConfigDir / "agent-r42.conf", R"({})"); - createTestConfig(cliConfigDir / "agent-r999.conf", R"({})"); + // Setup mock agent server + setupMockedAgentServer(); + // 2 commits + 1 rollback = 3 reloadConfig calls + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(3); - // Verify files exist - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r1.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r42.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r999.conf")); + // Create a session and make several commits to build history + std::string firstCommitSha; + std::string secondCommitSha; + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // Test extractRevisionNumber() method - EXPECT_EQ( - ConfigSession::extractRevisionNumber( - (cliConfigDir / "agent-r1.conf").string()), - 1); - EXPECT_EQ( - ConfigSession::extractRevisionNumber( - (cliConfigDir / "agent-r42.conf").string()), - 42); - EXPECT_EQ( - ConfigSession::extractRevisionNumber( - (cliConfigDir / "agent-r999.conf").string()), - 999); -} + // Simulate CLI command for first commit + session.addCommand("config interface eth1/1/1 description First version"); -TEST_F(ConfigSessionTestFixture, rollbackCreatesNewRevision) { - // This test actually calls the rollback() method with a specific revision - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; - fs::path symlinkPath = testEtcDir_ / "coop" / "agent.conf"; - fs::path sessionConfigPath = testHomeDir_ / ".fboss2" / "agent.conf"; + // First commit + auto& config1 = session.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "First version"; + session.saveConfig(); + auto result1 = session.commit(localhost()); + firstCommitSha = result1.commitSha; - // Remove the regular file created by SetUp - if (fs::exists(symlinkPath)) { - fs::remove(symlinkPath); + // Second commit (need new session after commit) } + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // Create revision files (simulating previous commits) - createTestConfig(cliConfigDir / "agent-r1.conf", R"({"revision": 1})"); - createTestConfig(cliConfigDir / "agent-r2.conf", R"({"revision": 2})"); - createTestConfig(cliConfigDir / "agent-r3.conf", R"({"revision": 3})"); - - // Create symlink pointing to r3 (current revision) - fs::create_symlink(cliConfigDir / "agent-r3.conf", symlinkPath); - - // Verify initial state - EXPECT_TRUE(fs::is_symlink(symlinkPath)); - EXPECT_EQ(fs::read_symlink(symlinkPath), cliConfigDir / "agent-r3.conf"); + // Simulate CLI command for second commit + session.addCommand("config interface eth1/1/1 description Second version"); - // Setup mock agent server - setupMockedAgentServer(); + auto& config2 = session.getAgentConfig(); + (*config2.sw()->ports())[0].description() = "Second version"; + session.saveConfig(); + auto result2 = session.commit(localhost()); + secondCommitSha = result2.commitSha; + } - // Expect reloadConfig to be called once - EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + // Verify current content is "Second version" + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("Second version")); + // Verify current metadata contains second command + EXPECT_THAT( + readFile(metadataPath), ::testing::HasSubstr("description Second")); - // Create a testable ConfigSession with test paths - TestableConfigSession session( - sessionConfigPath.string(), symlinkPath.string(), cliConfigDir.string()); + // Now rollback to first commit + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // Call the actual rollback method to rollback to r1 - int newRevision = session.rollback(localhost(), "r1"); + std::string rollbackSha = session.rollback(localhost(), firstCommitSha); - // Verify rollback created a new revision (r4) - EXPECT_EQ(newRevision, 4); - EXPECT_TRUE(fs::is_symlink(symlinkPath)); - EXPECT_EQ(fs::read_symlink(symlinkPath), cliConfigDir / "agent-r4.conf"); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r4.conf")); + // Verify rollback created a new commit + EXPECT_FALSE(rollbackSha.empty()); + EXPECT_NE(rollbackSha, firstCommitSha); + EXPECT_NE(rollbackSha, secondCommitSha); - // Verify r4 has same content as r1 (the target revision) - EXPECT_EQ( - readFile(cliConfigDir / "agent-r1.conf"), - readFile(cliConfigDir / "agent-r4.conf")); + // Verify config content is now "First version" + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("First version")); - // Verify old revisions still exist (rollback doesn't delete history) - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r1.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); -} + // Verify metadata was also rolled back to first version + std::string metadataContent = readFile(metadataPath); + EXPECT_THAT(metadataContent, ::testing::HasSubstr("description First")); + EXPECT_THAT( + metadataContent, + ::testing::Not(::testing::HasSubstr("description Second"))); -TEST_F(ConfigSessionTestFixture, rollbackToPreviousRevision) { - // This test actually calls the rollback() method without a revision argument - // to rollback to the previous revision - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; - fs::path symlinkPath = testEtcDir_ / "coop" / "agent.conf"; - fs::path sessionConfigPath = testHomeDir_ / ".fboss2" / "agent.conf"; + // Verify Git history has the rollback commit + auto& git = session.getGit(); + auto commits = git.log(cliConfigPath.string()); + EXPECT_EQ(commits.size(), 4); // Initial + 2 commits + rollback - // Remove the regular file created by SetUp - if (fs::exists(symlinkPath)) { - fs::remove(symlinkPath); + // Verify metadata file history + auto metadataCommits = git.log(metadataPath.string()); + EXPECT_EQ(metadataCommits.size(), 3); // 2 commits + rollback } +} - // Create revision files (simulating previous commits) - createTestConfig(cliConfigDir / "agent-r1.conf", R"({"revision": 1})"); - createTestConfig(cliConfigDir / "agent-r2.conf", R"({"revision": 2})"); - createTestConfig(cliConfigDir / "agent-r3.conf", R"({"revision": 3})"); - - // Create symlink pointing to r3 (current revision) - fs::create_symlink(cliConfigDir / "agent-r3.conf", symlinkPath); - - // Verify initial state - EXPECT_TRUE(fs::is_symlink(symlinkPath)); - EXPECT_EQ(fs::read_symlink(symlinkPath), cliConfigDir / "agent-r3.conf"); +TEST_F(ConfigSessionTestFixture, rollbackToPreviousCommit) { + // This test calls the rollback() method without a commit SHA argument + // to rollback to the previous commit + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Setup mock agent server setupMockedAgentServer(); + // 2 commits + 1 rollback = 3 reloadConfig calls + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(3); - // Expect reloadConfig to be called once - EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + // Create commits to build history + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // Create a testable ConfigSession with test paths - TestableConfigSession session( - sessionConfigPath.string(), symlinkPath.string(), cliConfigDir.string()); + auto& config1 = session.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "First version"; + session.saveConfig(); + session.commit(localhost()); + } + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // Call the actual rollback method without a revision (should go to previous) - int newRevision = session.rollback(localhost()); + auto& config2 = session.getAgentConfig(); + (*config2.sw()->ports())[0].description() = "Second version"; + session.saveConfig(); + session.commit(localhost()); + } - // Verify rollback to previous revision created r4 with content from r2 - EXPECT_EQ(newRevision, 4); - EXPECT_TRUE(fs::is_symlink(symlinkPath)); - EXPECT_EQ(fs::read_symlink(symlinkPath), cliConfigDir / "agent-r4.conf"); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r4.conf")); + // Verify current content is "Second version" + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("Second version")); - // Verify r4 has same content as r2 (the previous revision) - EXPECT_EQ( - readFile(cliConfigDir / "agent-r2.conf"), - readFile(cliConfigDir / "agent-r4.conf")); + // Rollback to previous commit (no argument) + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); + + std::string rollbackSha = session.rollback(localhost()); - // Verify old revisions still exist (rollback doesn't delete history) - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r1.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); + // Verify rollback succeeded + EXPECT_FALSE(rollbackSha.empty()); + + // Verify content is now "First version" (from previous commit) + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("First version")); + } } TEST_F(ConfigSessionTestFixture, actionLevelDefaultIsHitless) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Default action level should be HITLESS EXPECT_EQ( @@ -662,13 +608,9 @@ TEST_F(ConfigSessionTestFixture, actionLevelDefaultIsHitless) { TEST_F(ConfigSessionTestFixture, actionLevelUpdateAndGet) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Update to AGENT_WARMBOOT session.updateRequiredAction( @@ -682,13 +624,9 @@ TEST_F(ConfigSessionTestFixture, actionLevelUpdateAndGet) { TEST_F(ConfigSessionTestFixture, actionLevelHigherTakesPrecedence) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Update to AGENT_WARMBOOT first session.updateRequiredAction( @@ -706,13 +644,9 @@ TEST_F(ConfigSessionTestFixture, actionLevelHigherTakesPrecedence) { TEST_F(ConfigSessionTestFixture, actionLevelReset) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Set to AGENT_WARMBOOT session.updateRequiredAction( @@ -729,15 +663,12 @@ TEST_F(ConfigSessionTestFixture, actionLevelReset) { TEST_F(ConfigSessionTestFixture, actionLevelPersistsToMetadataFile) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; - fs::path metadataFile = sessionDir / "conf_metadata.json"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; - // Create a ConfigSession and set action level + // Create a ConfigSession and set action level via saveConfig { TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); // Set to AGENT_WARMBOOT session.updateRequiredAction( @@ -760,7 +691,8 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsToMetadataFile) { TEST_F(ConfigSessionTestFixture, actionLevelLoadsFromMetadataFile) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; - fs::path metadataFile = sessionDir / "conf_metadata.json"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; // Create session directory and metadata file manually fs::create_directories(sessionDir); @@ -771,13 +703,10 @@ TEST_F(ConfigSessionTestFixture, actionLevelLoadsFromMetadataFile) { // Also create the session config file (otherwise session will overwrite from // system) - fs::copy_file(systemConfigPath_, sessionConfig); + fs::copy_file(cliConfigPath, sessionConfig); // Create a ConfigSession - should load action level from metadata file - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Verify action level was loaded EXPECT_EQ( @@ -787,14 +716,11 @@ TEST_F(ConfigSessionTestFixture, actionLevelLoadsFromMetadataFile) { TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; - // First session: set action level + // First session: set action level via saveConfig { TestableConfigSession session1( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); session1.updateRequiredAction( cli::ServiceType::AGENT, cli::ConfigActionLevel::AGENT_WARMBOOT); @@ -803,9 +729,7 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { // Second session: verify action level was persisted { TestableConfigSession session2( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); EXPECT_EQ( session2.getRequiredAction(cli::ServiceType::AGENT), @@ -813,4 +737,418 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { } } +TEST_F(ConfigSessionTestFixture, commandTrackingBasic) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + + // Create a ConfigSession, execute command, and verify persistence + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); + + // Initially, no commands should be recorded + EXPECT_TRUE(session.getCommands().empty()); + + // Simulate a command and save config + session.addCommand("config interface eth1/1/1 description Test change"); + auto& config = session.getAgentConfig(); + auto& ports = *config.sw()->ports(); + ASSERT_FALSE(ports.empty()); + ports[0].description() = "Test change"; + session.saveConfig(); + + // Verify command was recorded in memory + EXPECT_EQ(1, session.getCommands().size()); + EXPECT_EQ( + "config interface eth1/1/1 description Test change", + session.getCommands()[0]); + } + + // Verify metadata file exists and has commands persisted + EXPECT_TRUE(fs::exists(metadataFile)); + std::string content = readFile(metadataFile); + + // Parse the JSON and verify structure + folly::dynamic json = folly::parseJson(content); + EXPECT_TRUE(json.isObject()); + EXPECT_TRUE(json.count("commands")); + EXPECT_TRUE(json["commands"].isArray()); + EXPECT_EQ(1, json["commands"].size()); + EXPECT_EQ( + "config interface eth1/1/1 description Test change", + json["commands"][0].asString()); +} + +TEST_F(ConfigSessionTestFixture, commandTrackingMultipleCommands) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + + // Create a ConfigSession + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); + + // Execute multiple commands + auto& config = session.getAgentConfig(); + auto& ports = *config.sw()->ports(); + ASSERT_FALSE(ports.empty()); + + session.addCommand("config interface eth1/1/1 mtu 9000"); + ports[0].description() = "First change"; + session.saveConfig(); + + session.addCommand("config interface eth1/1/1 description Test"); + ports[0].description() = "Second change"; + session.saveConfig(); + + session.addCommand("config interface eth1/1/1 speed 100G"); + ports[0].description() = "Third change"; + session.saveConfig(); + + // Verify all commands were recorded in order + EXPECT_EQ(3, session.getCommands().size()); + EXPECT_EQ("config interface eth1/1/1 mtu 9000", session.getCommands()[0]); + EXPECT_EQ( + "config interface eth1/1/1 description Test", session.getCommands()[1]); + EXPECT_EQ("config interface eth1/1/1 speed 100G", session.getCommands()[2]); +} + +TEST_F(ConfigSessionTestFixture, commandTrackingPersistsAcrossSessions) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + + // First session: execute some commands + { + TestableConfigSession session1( + sessionDir.string(), systemConfigDir_.string()); + + auto& config = session1.getAgentConfig(); + auto& ports = *config.sw()->ports(); + ASSERT_FALSE(ports.empty()); + + session1.addCommand("config interface eth1/1/1 mtu 9000"); + ports[0].description() = "First change"; + session1.saveConfig(); + + session1.addCommand("config interface eth1/1/1 description Test"); + ports[0].description() = "Second change"; + session1.saveConfig(); + } + + // Second session: verify commands were persisted + { + TestableConfigSession session2( + sessionDir.string(), systemConfigDir_.string()); + + EXPECT_EQ(2, session2.getCommands().size()); + EXPECT_EQ("config interface eth1/1/1 mtu 9000", session2.getCommands()[0]); + EXPECT_EQ( + "config interface eth1/1/1 description Test", + session2.getCommands()[1]); + } +} + +TEST_F(ConfigSessionTestFixture, commandTrackingClearedOnReset) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + + // Create a ConfigSession and add some commands + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); + + auto& config = session.getAgentConfig(); + auto& ports = *config.sw()->ports(); + ASSERT_FALSE(ports.empty()); + + session.addCommand("config interface eth1/1/1 mtu 9000"); + ports[0].description() = "Test change"; + session.saveConfig(); + + EXPECT_EQ(1, session.getCommands().size()); + + // Reset the action level (which also clears commands) + session.resetRequiredAction(cli::AgentType::WEDGE_AGENT); + + // Verify commands were cleared + EXPECT_TRUE(session.getCommands().empty()); +} + +TEST_F(ConfigSessionTestFixture, commandTrackingLoadsFromMetadataFile) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "cli_metadata.json"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + + // Create session directory and metadata file manually + fs::create_directories(sessionDir); + std::ofstream metaFile(metadataFile); + metaFile << R"({ + "action": {"WEDGE_AGENT": "HITLESS"}, + "commands": ["cmd1", "cmd2", "cmd3"] + })"; + metaFile.close(); + + // Also create the session config file + fs::copy_file(cliConfigPath, sessionConfig); + + // Create a ConfigSession - should load commands from metadata file + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); + + // Verify commands were loaded + EXPECT_EQ(3, session.getCommands().size()); + EXPECT_EQ("cmd1", session.getCommands()[0]); + EXPECT_EQ("cmd2", session.getCommands()[1]); + EXPECT_EQ("cmd3", session.getCommands()[2]); +} + +// Test that concurrent sessions are detected and rejected +// Scenario: user1 and user2 both start sessions based on the same commit, +// user1 commits first, then user2 tries to commit and should fail. +TEST_F(ConfigSessionTestFixture, concurrentSessionConflict) { + fs::path sessionDir1 = testHomeDir_ / ".fboss2_user1"; + fs::path sessionDir2 = testHomeDir_ / ".fboss2_user2"; + + // Setup mock agent server + setupMockedAgentServer(); + // Only user1's commit should succeed, so only 1 reloadConfig call + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + + // User1 creates a session (captures current HEAD as base) + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + + // User2 also creates a session at the same time (same base) + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + // User1 makes a change and commits + auto& config1 = session1.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "User1 change"; + session1.saveConfig(); + auto result1 = session1.commit(localhost()); + EXPECT_FALSE(result1.commitSha.empty()); + + // User2 makes a different change + auto& config2 = session2.getAgentConfig(); + (*config2.sw()->ports())[0].description() = "User2 change"; + session2.saveConfig(); + + // User2 tries to commit but should fail because user1 already committed + EXPECT_THROW( + { + try { + session2.commit(localhost()); + } catch (const std::runtime_error& e) { + // Verify the error message mentions the conflict + EXPECT_THAT( + e.what(), + ::testing::HasSubstr("system configuration has changed")); + throw; + } + }, + std::runtime_error); + + // Verify that only user1's change is in the system config + Git git(systemConfigDir_.string()); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + std::string content; + EXPECT_TRUE(folly::readFile(cliConfigPath.c_str(), content)); + EXPECT_THAT(content, ::testing::HasSubstr("User1 change")); + EXPECT_THAT(content, ::testing::Not(::testing::HasSubstr("User2 change"))); +} + +TEST_F(ConfigSessionTestFixture, rebaseSuccessNoConflict) { + // Test successful rebase when user2's changes don't conflict with user1's + fs::path sessionDir1 = testHomeDir_ / ".fboss2_user1"; + fs::path sessionDir2 = testHomeDir_ / ".fboss2_user2"; + + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(2); + + // User1 creates a session + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + + // User2 also creates a session at the same time (same base) + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + // User1 changes port[0] description and commits + auto& config1 = session1.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "User1 change"; + session1.saveConfig(); + auto result1 = session1.commit(localhost()); + EXPECT_FALSE(result1.commitSha.empty()); + + // User2 changes port[1] description (non-conflicting - different port) + auto& config2 = session2.getAgentConfig(); + ASSERT_GE(config2.sw()->ports()->size(), 2) << "Need at least 2 ports"; + (*config2.sw()->ports())[1].description() = "User2 change"; + session2.saveConfig(); + + // User2 tries to commit but fails due to stale base + EXPECT_THROW(session2.commit(localhost()), std::runtime_error); + + // User2 rebases - should succeed since changes don't conflict + EXPECT_NO_THROW(session2.rebase()); + + // Now user2 can commit + auto result2 = session2.commit(localhost()); + EXPECT_FALSE(result2.commitSha.empty()); + + // Verify both changes are in the final config + Git git(systemConfigDir_.string()); + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + std::string content; + EXPECT_TRUE(folly::readFile(cliConfigPath.c_str(), content)); + EXPECT_THAT(content, ::testing::HasSubstr("User1 change")); + EXPECT_THAT(content, ::testing::HasSubstr("User2 change")); +} + +TEST_F(ConfigSessionTestFixture, rebaseFailsOnConflict) { + // Test that rebase fails when user2's changes conflict with user1's + fs::path sessionDir1 = testHomeDir_ / ".fboss2_user1"; + fs::path sessionDir2 = testHomeDir_ / ".fboss2_user2"; + + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + + // User1 creates a session + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + + // User2 also creates a session at the same time (same base) + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + // User1 changes port[0] description to "User1 change" + auto& config1 = session1.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "User1 change"; + session1.saveConfig(); + auto result1 = session1.commit(localhost()); + EXPECT_FALSE(result1.commitSha.empty()); + + // User2 changes the SAME port[0] description to "User2 change" (conflict!) + auto& config2 = session2.getAgentConfig(); + (*config2.sw()->ports())[0].description() = "User2 change"; + session2.saveConfig(); + + // User2 tries to rebase but should fail due to conflict + EXPECT_THROW( + { + try { + session2.rebase(); + } catch (const std::runtime_error& e) { + EXPECT_THAT(e.what(), ::testing::HasSubstr("conflict")); + throw; + } + }, + std::runtime_error); +} + +TEST_F(ConfigSessionTestFixture, rebaseNotNeeded) { + // Test that rebase throws when session is already up-to-date + fs::path sessionDir = testHomeDir_ / ".fboss2"; + + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(0); + + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); + + // Make a change but don't commit yet + auto& config = session.getAgentConfig(); + (*config.sw()->ports())[0].description() = "My change"; + session.saveConfig(); + + // Try to rebase - should fail because we're already on HEAD + EXPECT_THROW( + { + try { + session.rebase(); + } catch (const std::runtime_error& e) { + EXPECT_THAT(e.what(), ::testing::HasSubstr("No rebase needed")); + throw; + } + }, + std::runtime_error); +} + +// Tests 3-way merge algorithm through rebase() covering: +// - Only session changed (head == base) +// - Only head changed (session == base) +// - Both changed to same value (no conflict) +// - Both changed to different values (conflict) +TEST_F(ConfigSessionTestFixture, threeWayMergeScenarios) { + fs::path sessionDir1 = testHomeDir_ / ".fboss2_user1"; + fs::path sessionDir2 = testHomeDir_ / ".fboss2_user2"; + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + + setupMockedAgentServer(); + // 5 commits: 2 in scenario 1, 2 in scenario 2, 1 in scenario 3 (rebase fails) + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(5); + + // Scenario 1: Only session changed, head unchanged + // User1 commits, User2 changes different field - should merge cleanly + { + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + (*session1.getAgentConfig().sw()->ports())[0].name() = "port0_renamed"; + session1.saveConfig(); + session1.commit(localhost()); + + (*session2.getAgentConfig().sw()->ports())[1].description() = "port1_desc"; + session2.saveConfig(); + EXPECT_NO_THROW(session2.rebase()); + session2.commit(localhost()); + + std::string content; + EXPECT_TRUE(folly::readFile(cliConfigPath.c_str(), content)); + EXPECT_THAT(content, ::testing::HasSubstr("port0_renamed")); + EXPECT_THAT(content, ::testing::HasSubstr("port1_desc")); + } + + // Scenario 2: Both changed same field to identical value - no conflict + { + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + (*session1.getAgentConfig().sw()->ports())[0].description() = "same_value"; + session1.saveConfig(); + session1.commit(localhost()); + + (*session2.getAgentConfig().sw()->ports())[0].description() = "same_value"; + session2.saveConfig(); + EXPECT_NO_THROW(session2.rebase()); + session2.commit(localhost()); + + std::string content; + EXPECT_TRUE(folly::readFile(cliConfigPath.c_str(), content)); + EXPECT_THAT(content, ::testing::HasSubstr("same_value")); + } + + // Scenario 3: Both changed same field to different values - conflict + { + TestableConfigSession session1( + sessionDir1.string(), systemConfigDir_.string()); + TestableConfigSession session2( + sessionDir2.string(), systemConfigDir_.string()); + + (*session1.getAgentConfig().sw()->ports())[0].description() = "user1_value"; + session1.saveConfig(); + session1.commit(localhost()); + + (*session2.getAgentConfig().sw()->ports())[0].description() = "user2_value"; + session2.saveConfig(); + EXPECT_THROW( + { + try { + session2.rebase(); + } catch (const std::runtime_error& e) { + EXPECT_THAT(e.what(), ::testing::HasSubstr("conflict")); + throw; + } + }, + std::runtime_error); + } +} + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigVlanPortTaggingModeTest.cpp b/fboss/cli/fboss2/test/CmdConfigVlanPortTaggingModeTest.cpp new file mode 100644 index 0000000000000..128338a09e13f --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigVlanPortTaggingModeTest.cpp @@ -0,0 +1,323 @@ +/* + * 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 +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h" +#include "fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigVlanPortTaggingModeTestFixture : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss_tagging_mode_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + + // Create test directories + fs::create_directories(testHomeDir_); + systemConfigDir_ = testEtcDir_ / "coop"; + fs::create_directories(systemConfigDir_ / "cli"); + + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("HOME", testHomeDir_.c_str(), 1); + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("USER", "testuser", 1); + + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000, + "ingressVlan": 100 + }, + { + "logicalID": 2, + "name": "eth1/2/1", + "state": 2, + "speed": 100000, + "ingressVlan": 100 + } + ], + "vlans": [ + { + "id": 100, + "name": "default" + }, + { + "id": 200, + "name": "test-vlan" + } + ], + "vlanPorts": [ + { + "vlanID": 100, + "logicalPort": 1, + "spanningTreeState": 2, + "emitTags": false + }, + { + "vlanID": 100, + "logicalPort": 2, + "spanningTreeState": 2, + "emitTags": false + } + ] + } +})"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); + + // Initialize the ConfigSession singleton for all tests + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestConfig(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; +}; + +// ============================================================================ +// Tests for TaggingModeArg validation +// ============================================================================ + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeTaggedValid) { + TaggingModeArg arg({"tagged"}); + EXPECT_TRUE(arg.getEmitTags()); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeUntaggedValid) { + TaggingModeArg arg({"untagged"}); + EXPECT_FALSE(arg.getEmitTags()); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeTaggedUpperCase) { + TaggingModeArg arg({"TAGGED"}); + EXPECT_TRUE(arg.getEmitTags()); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeUntaggedUpperCase) { + TaggingModeArg arg({"UNTAGGED"}); + EXPECT_FALSE(arg.getEmitTags()); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeMixedCase) { + TaggingModeArg arg({"TaGgEd"}); + EXPECT_TRUE(arg.getEmitTags()); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeEmptyInvalid) { + EXPECT_THROW(TaggingModeArg({}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeTooManyArgsInvalid) { + EXPECT_THROW(TaggingModeArg({"tagged", "extra"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, taggingModeInvalidValue) { + EXPECT_THROW(TaggingModeArg({"invalid"}), std::invalid_argument); +} + +TEST_F( + CmdConfigVlanPortTaggingModeTestFixture, + taggingModeInvalidErrorMessage) { + try { + auto unused = TaggingModeArg({"bad-mode"}); + (void)unused; + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("Invalid tagging mode")); + EXPECT_THAT(errorMsg, HasSubstr("bad-mode")); + EXPECT_THAT(errorMsg, HasSubstr("tagged")); + EXPECT_THAT(errorMsg, HasSubstr("untagged")); + } +} + +// ============================================================================ +// Tests for CmdConfigVlanPortTaggingMode::queryClient +// ============================================================================ + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, setTaggingModeTaggedSuccess) { + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"100"}); + utils::PortList portList({"eth1/1/1"}); + TaggingModeArg taggingMode({"tagged"}); + + auto result = cmd.queryClient(localhost(), vlanId, portList, taggingMode); + + EXPECT_THAT(result, HasSubstr("Successfully set port")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + EXPECT_THAT(result, HasSubstr("tagged")); + EXPECT_THAT(result, HasSubstr("VLAN 100")); + + // Verify the emitTags was updated in config + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + bool found = false; + for (const auto& vlanPort : *swConfig.vlanPorts()) { + if (*vlanPort.vlanID() == 100 && *vlanPort.logicalPort() == 1) { + EXPECT_TRUE(*vlanPort.emitTags()); + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, setTaggingModeUntaggedSuccess) { + // First set to tagged + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"100"}); + utils::PortList portList({"eth1/1/1"}); + TaggingModeArg taggedMode({"tagged"}); + cmd.queryClient(localhost(), vlanId, portList, taggedMode); + + // Now set to untagged + TaggingModeArg untaggedMode({"untagged"}); + auto result = cmd.queryClient(localhost(), vlanId, portList, untaggedMode); + + EXPECT_THAT(result, HasSubstr("Successfully set port")); + EXPECT_THAT(result, HasSubstr("untagged")); + + // Verify the emitTags was updated in config + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + for (const auto& vlanPort : *swConfig.vlanPorts()) { + if (*vlanPort.vlanID() == 100 && *vlanPort.logicalPort() == 1) { + EXPECT_FALSE(*vlanPort.emitTags()); + break; + } + } +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, setTaggingModeVlanNotFound) { + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"999"}); // VLAN doesn't exist + utils::PortList portList({"eth1/1/1"}); + TaggingModeArg taggingMode({"tagged"}); + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, portList, taggingMode), + std::invalid_argument); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, setTaggingModePortNotFound) { + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"100"}); + utils::PortList portList({"eth99/99/99"}); // Port doesn't exist + TaggingModeArg taggingMode({"tagged"}); + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, portList, taggingMode), + std::invalid_argument); +} + +TEST_F( + CmdConfigVlanPortTaggingModeTestFixture, + setTaggingModePortNotMemberOfVlan) { + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"200"}); // VLAN exists but port is not a member + utils::PortList portList({"eth1/1/1"}); + TaggingModeArg taggingMode({"tagged"}); + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, portList, taggingMode), + std::invalid_argument); +} + +TEST_F(CmdConfigVlanPortTaggingModeTestFixture, setTaggingModeMultiplePorts) { + auto cmd = CmdConfigVlanPortTaggingMode(); + VlanId vlanId({"100"}); + utils::PortList portList({"eth1/1/1", "eth1/2/1"}); + TaggingModeArg taggingMode({"tagged"}); + + auto result = cmd.queryClient(localhost(), vlanId, portList, taggingMode); + + EXPECT_THAT(result, HasSubstr("Successfully set 2 ports")); + EXPECT_THAT(result, HasSubstr("tagged")); + EXPECT_THAT(result, HasSubstr("VLAN 100")); + + // Verify both ports were updated + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + int updatedCount = 0; + for (const auto& vlanPort : *swConfig.vlanPorts()) { + if (*vlanPort.vlanID() == 100) { + EXPECT_TRUE(*vlanPort.emitTags()); + updatedCount++; + } + } + EXPECT_EQ(updatedCount, 2); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdConfigVlanStaticMacTest.cpp b/fboss/cli/fboss2/test/CmdConfigVlanStaticMacTest.cpp new file mode 100644 index 0000000000000..aa68ecd793ecf --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigVlanStaticMacTest.cpp @@ -0,0 +1,411 @@ +/* + * 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 +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h" +#include "fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" +#include "fboss/cli/fboss2/session/Git.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigVlanStaticMacTestFixture : public CmdHandlerTestBase { + public: + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss_static_mac_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + + // Create test directories + // Structure: systemConfigDir_ = testEtcDir_/coop (git repo root) + // - agent.conf (symlink -> cli/agent.conf) + // - cli/agent.conf (actual config file) + fs::create_directories(testHomeDir_); + systemConfigDir_ = testEtcDir_ / "coop"; + fs::create_directories(systemConfigDir_ / "cli"); + + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("HOME", testHomeDir_.c_str(), 1); + // NOLINTNEXTLINE(concurrency-mt-unsafe) - acceptable in unit tests + setenv("USER", "testuser", 1); + + // Create a test system config file at cli/agent.conf + fs::path cliConfigPath = systemConfigDir_ / "cli" / "agent.conf"; + createTestConfig(cliConfigPath, R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000, + "ingressVlan": 100 + }, + { + "logicalID": 2, + "name": "eth1/2/1", + "state": 2, + "speed": 100000, + "ingressVlan": 100 + } + ], + "vlans": [ + { + "id": 100, + "name": "default" + }, + { + "id": 200, + "name": "test-vlan" + } + ] + } +})"); + + // Create symlink at /etc/coop/agent.conf -> cli/agent.conf + fs::create_symlink("cli/agent.conf", systemConfigDir_ / "agent.conf"); + + // Initialize Git repository and create initial commit + Git git(systemConfigDir_.string()); + git.init(); + git.commit({cliConfigPath.string()}, "Initial commit"); + + // Initialize the ConfigSession singleton for all tests + sessionConfigDir_ = testHomeDir_ / ".fboss2"; + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + std::error_code ec; + if (fs::exists(testHomeDir_)) { + fs::remove_all(testHomeDir_, ec); + } + if (fs::exists(testEtcDir_)) { + fs::remove_all(testEtcDir_, ec); + } + CmdHandlerTestBase::TearDown(); + } + + protected: + void createTestConfig(const fs::path& path, const std::string& content) { + std::ofstream file(path); + file << content; + file.close(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; +}; + +// ============================================================================ +// Tests for VlanId validation (from CmdConfigVlan.h) +// ============================================================================ + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdValidMin) { + VlanId vlanId({"1"}); + EXPECT_EQ(vlanId.getVlanId(), 1); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdValidMax) { + VlanId vlanId({"4094"}); + EXPECT_EQ(vlanId.getVlanId(), 4094); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdValidMid) { + VlanId vlanId({"100"}); + EXPECT_EQ(vlanId.getVlanId(), 100); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdZeroInvalid) { + EXPECT_THROW(VlanId({"0"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdTooHighInvalid) { + EXPECT_THROW(VlanId({"4095"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdNegativeInvalid) { + EXPECT_THROW(VlanId({"-1"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdNonNumericInvalid) { + EXPECT_THROW(VlanId({"abc"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdEmptyInvalid) { + EXPECT_THROW(VlanId({}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdMultipleValuesInvalid) { + EXPECT_THROW(VlanId({"100", "200"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, vlanIdOutOfRangeErrorMessage) { + try { + auto unused = VlanId({"9999"}); + (void)unused; + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("VLAN ID must be between 1 and 4094")); + EXPECT_THAT(errorMsg, HasSubstr("9999")); + } +} + +// ============================================================================ +// Tests for MacAndPortArg validation (from CmdConfigVlanStaticMacAdd.h) +// ============================================================================ + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortValidArgs) { + MacAndPortArg args({"00:11:22:33:44:55", "eth1/1/1"}); + EXPECT_EQ(args.getMacAddress(), "00:11:22:33:44:55"); + EXPECT_EQ(args.getPortName(), "eth1/1/1"); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortValidUpperCaseMac) { + MacAndPortArg args({"AA:BB:CC:DD:EE:FF", "eth1/2/1"}); + EXPECT_EQ(args.getMacAddress(), "AA:BB:CC:DD:EE:FF"); + EXPECT_EQ(args.getPortName(), "eth1/2/1"); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortMissingPort) { + EXPECT_THROW(MacAndPortArg({"00:11:22:33:44:55"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortTooManyArgs) { + EXPECT_THROW( + MacAndPortArg({"00:11:22:33:44:55", "eth1/1/1", "extra"}), + std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortEmptyArgs) { + EXPECT_THROW(MacAndPortArg({}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortInvalidMacFormat) { + EXPECT_THROW( + MacAndPortArg({"invalid-mac", "eth1/1/1"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAndPortInvalidMacErrorMessage) { + try { + auto unused = MacAndPortArg({"not-a-mac", "eth1/1/1"}); + (void)unused; + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("Invalid MAC address format")); + EXPECT_THAT(errorMsg, HasSubstr("not-a-mac")); + } +} + +// ============================================================================ +// Tests for MacAddressArg validation (from CmdConfigVlanStaticMacDelete.h) +// ============================================================================ + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgValid) { + MacAddressArg arg({"00:11:22:33:44:55"}); + EXPECT_EQ(arg.getMacAddress(), "00:11:22:33:44:55"); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgValidUpperCase) { + MacAddressArg arg({"AA:BB:CC:DD:EE:FF"}); + EXPECT_EQ(arg.getMacAddress(), "AA:BB:CC:DD:EE:FF"); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgEmpty) { + EXPECT_THROW(MacAddressArg({}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgTooManyArgs) { + EXPECT_THROW( + MacAddressArg({"00:11:22:33:44:55", "extra"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgInvalidFormat) { + EXPECT_THROW(MacAddressArg({"invalid"}), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, macAddressArgInvalidErrorMessage) { + try { + auto unused = MacAddressArg({"bad-mac"}); + (void)unused; + FAIL() << "Expected std::invalid_argument"; + } catch (const std::invalid_argument& e) { + std::string errorMsg = e.what(); + EXPECT_THAT(errorMsg, HasSubstr("Invalid MAC address format")); + EXPECT_THAT(errorMsg, HasSubstr("bad-mac")); + } +} + +// ============================================================================ +// Tests for CmdConfigVlanStaticMacAdd::queryClient +// ============================================================================ + +TEST_F(CmdConfigVlanStaticMacTestFixture, addStaticMacSuccess) { + auto cmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"100"}); + MacAndPortArg macAndPort({"00:11:22:33:44:55", "eth1/1/1"}); + + auto result = cmd.queryClient(localhost(), vlanId, macAndPort); + + EXPECT_THAT(result, HasSubstr("Successfully added static MAC entry")); + EXPECT_THAT(result, HasSubstr("00:11:22:33:44:55")); + EXPECT_THAT(result, HasSubstr("VLAN 100")); + EXPECT_THAT(result, HasSubstr("eth1/1/1")); + + // Verify the entry was added to config + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + ASSERT_TRUE(swConfig.staticMacAddrs().has_value()); + const auto& staticMacAddrs = *swConfig.staticMacAddrs(); + ASSERT_EQ(staticMacAddrs.size(), 1); + EXPECT_EQ(*staticMacAddrs.at(0).vlanID(), 100); + EXPECT_EQ(*staticMacAddrs.at(0).macAddress(), "00:11:22:33:44:55"); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, addStaticMacVlanNotFound) { + auto cmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"999"}); // VLAN doesn't exist + MacAndPortArg macAndPort({"00:11:22:33:44:55", "eth1/1/1"}); + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, macAndPort), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, addStaticMacPortNotFound) { + auto cmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"100"}); + MacAndPortArg macAndPort( + {"00:11:22:33:44:55", "eth99/99/99"}); // Port doesn't exist + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, macAndPort), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, addStaticMacDuplicateEntry) { + auto cmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"100"}); + MacAndPortArg macAndPort({"00:11:22:33:44:55", "eth1/1/1"}); + + // First add should succeed + auto result = cmd.queryClient(localhost(), vlanId, macAndPort); + EXPECT_THAT(result, HasSubstr("Successfully added")); + + // Second add of same MAC/VLAN should succeed (idempotent) + auto result2 = cmd.queryClient(localhost(), vlanId, macAndPort); + EXPECT_THAT(result2, HasSubstr("already exists")); +} + +// ============================================================================ +// Tests for CmdConfigVlanStaticMacDelete::queryClient +// ============================================================================ + +TEST_F(CmdConfigVlanStaticMacTestFixture, deleteStaticMacSuccess) { + // First add a static MAC entry + auto addCmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"100"}); + MacAndPortArg macAndPort({"00:11:22:33:44:55", "eth1/1/1"}); + addCmd.queryClient(localhost(), vlanId, macAndPort); + + // Verify it was added + { + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + ASSERT_TRUE(swConfig.staticMacAddrs().has_value()); + ASSERT_EQ(swConfig.staticMacAddrs()->size(), 1); + } + + // Now delete it + auto deleteCmd = CmdConfigVlanStaticMacDelete(); + MacAddressArg macArg({"00:11:22:33:44:55"}); + auto result = deleteCmd.queryClient(localhost(), vlanId, macArg); + + EXPECT_THAT(result, HasSubstr("Successfully deleted static MAC entry")); + EXPECT_THAT(result, HasSubstr("00:11:22:33:44:55")); + EXPECT_THAT(result, HasSubstr("VLAN 100")); + + // Verify it was removed + { + auto& config = ConfigSession::getInstance().getAgentConfig(); + auto& swConfig = *config.sw(); + EXPECT_TRUE( + !swConfig.staticMacAddrs().has_value() || + swConfig.staticMacAddrs()->empty()); + } +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, deleteStaticMacVlanNotFound) { + auto cmd = CmdConfigVlanStaticMacDelete(); + VlanId vlanId({"999"}); // VLAN doesn't exist + MacAddressArg macArg({"00:11:22:33:44:55"}); + + EXPECT_THROW( + cmd.queryClient(localhost(), vlanId, macArg), std::invalid_argument); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, deleteStaticMacEntryNotFound) { + // First add a different entry so staticMacAddrs is not empty + auto addCmd = CmdConfigVlanStaticMacAdd(); + VlanId vlanId({"100"}); + MacAndPortArg macAndPort({"AA:BB:CC:DD:EE:FF", "eth1/1/1"}); + addCmd.queryClient(localhost(), vlanId, macAndPort); + + auto cmd = CmdConfigVlanStaticMacDelete(); + MacAddressArg macArg({"00:11:22:33:44:55"}); // Entry doesn't exist + + // Should succeed (idempotent) + auto result = cmd.queryClient(localhost(), vlanId, macArg); + EXPECT_THAT(result, HasSubstr("does not exist")); +} + +TEST_F(CmdConfigVlanStaticMacTestFixture, deleteStaticMacNoEntriesConfigured) { + auto cmd = CmdConfigVlanStaticMacDelete(); + VlanId vlanId({"100"}); + MacAddressArg macArg({"00:11:22:33:44:55"}); + + // Should succeed (idempotent) + auto result = cmd.queryClient(localhost(), vlanId, macArg); + EXPECT_THAT(result, HasSubstr("does not exist")); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdListConfigTest.cpp b/fboss/cli/fboss2/test/CmdListConfigTest.cpp new file mode 100644 index 0000000000000..72823dd6afeca --- /dev/null +++ b/fboss/cli/fboss2/test/CmdListConfigTest.cpp @@ -0,0 +1,30 @@ +/* + * 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 + +#include "fboss/cli/fboss2/CmdList.h" +#include "fboss/cli/fboss2/CmdSubcommands.h" + +namespace facebook::fboss { + +// This test verifies that the command trees can be successfully registered +// with CLI11 without throwing CLI::OptionAlreadyAdded exceptions due to +// duplicate subcommand names. +TEST(CmdListConfigTest, noDuplicateSubcommands) { + CLI::App app{"Test CLI"}; + + // This will throw CLI::OptionAlreadyAdded if there are duplicate subcommands + EXPECT_NO_THROW( + CmdSubcommands().init( + app, kCommandTree(), kAdditionalCommandTree(), kSpecialCommands())); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/CmdShowRunningConfigTest.cpp b/fboss/cli/fboss2/test/CmdShowRunningConfigTest.cpp new file mode 100644 index 0000000000000..6624f949c864a --- /dev/null +++ b/fboss/cli/fboss2/test/CmdShowRunningConfigTest.cpp @@ -0,0 +1,42 @@ +// (c) Facebook, Inc. and its affiliates. Confidential and proprietary. + +#include +#include + +#include "fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdShowRunningConfigTestFixture : public CmdHandlerTestBase { + public: + std::string mockConfig; + std::string expectedPrettyConfig; + + void SetUp() override { + CmdHandlerTestBase::SetUp(); + // A simple JSON config for testing + mockConfig = R"({"sw":{"ports":[]}})"; + // folly::toPrettyJson formats with 2-space indent + expectedPrettyConfig = R"({ + "sw": { + "ports": [] + } +})"; + } +}; + +TEST_F(CmdShowRunningConfigTestFixture, queryClient) { + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), getRunningConfig(_)) + .WillOnce(Invoke([&](std::string& config) { config = mockConfig; })); + + auto cmd = CmdShowRunningConfig(); + auto result = cmd.queryClient(localhost()); + + EXPECT_EQ(result, expectedPrettyConfig); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/GitTest.cpp b/fboss/cli/fboss2/test/GitTest.cpp new file mode 100644 index 0000000000000..2f09a6f3e5ce1 --- /dev/null +++ b/fboss/cli/fboss2/test/GitTest.cpp @@ -0,0 +1,125 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/cli/fboss2/session/Git.h" + +namespace fs = std::filesystem; + +namespace facebook::fboss { + +class GitTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a unique temporary directory for each test + auto tempBase = fs::temp_directory_path(); + auto uniquePath = + boost::filesystem::unique_path("git_test_%%%%-%%%%-%%%%-%%%%"); + testRepoPath_ = tempBase / uniquePath.string(); + fs::create_directories(testRepoPath_); + } + + void TearDown() override { + // Clean up test directory + std::error_code ec; + if (fs::exists(testRepoPath_)) { + fs::remove_all(testRepoPath_, ec); + } + } + + void writeFile(const std::string& filename, const std::string& content) { + if (!folly::writeFile(content, (testRepoPath_ / filename).c_str())) { + throw std::runtime_error( + fmt::format( + "Failed to write file {}: {}", filename, folly::errnoStr(errno))); + } + } + + std::string readFile(const std::string& filename) { + std::string content; + if (!folly::readFile((testRepoPath_ / filename).c_str(), content)) { + throw std::runtime_error( + fmt::format( + "Failed to read file {}: {}", filename, folly::errnoStr(errno))); + } + return content; + } + + fs::path testRepoPath_; +}; + +TEST_F(GitTest, BasicOperations) { + Git git(testRepoPath_.string()); + + // Before init + EXPECT_FALSE(git.isRepository()); + + // After init + git.init(); + EXPECT_TRUE(git.isRepository()); + EXPECT_TRUE(fs::exists(testRepoPath_ / ".git")); + EXPECT_FALSE(git.hasCommits()); + EXPECT_TRUE(git.getHead().empty()); + + // First commit + writeFile("config.txt", "version 1"); + std::string sha1First = + git.commit({"config.txt"}, "Version 1", "User", "user@test.com"); + EXPECT_FALSE(sha1First.empty()); + EXPECT_EQ(40, sha1First.length()); // SHA1 is 40 hex characters + EXPECT_TRUE(git.hasCommits()); + EXPECT_EQ(sha1First, git.getHead()); + + // Check log after first commit + auto commits = git.log("config.txt"); + ASSERT_EQ(1, commits.size()); + EXPECT_EQ(sha1First, commits[0].sha1); + EXPECT_EQ("User", commits[0].authorName); + EXPECT_EQ("user@test.com", commits[0].authorEmail); + EXPECT_EQ("Version 1", commits[0].subject); + EXPECT_GT(commits[0].timestamp, 0); + + // Second commit - modify the same file + writeFile("config.txt", "version 2"); + std::string sha1Second = + git.commit({"config.txt"}, "Version 2", "User", "user@test.com"); + EXPECT_EQ(sha1Second, git.getHead()); + + // Check log with no limit (should return both commits, most recent first) + commits = git.log("config.txt"); + ASSERT_EQ(2, commits.size()); + EXPECT_EQ(sha1Second, commits[0].sha1); + EXPECT_EQ(sha1First, commits[1].sha1); + + // Check log with limit of 1 (should return only most recent) + auto limitedCommits = git.log("config.txt", 1); + ASSERT_EQ(1, limitedCommits.size()); + EXPECT_EQ(sha1Second, limitedCommits[0].sha1); + + // Retrieve file content from first commit + std::string contentAtFirst = git.fileAtRevision(sha1First, "config.txt"); + EXPECT_EQ("version 1", contentAtFirst); + + // Retrieve file content from second commit + std::string contentAtSecond = git.fileAtRevision(sha1Second, "config.txt"); + EXPECT_EQ("version 2", contentAtSecond); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/TestableConfigSession.h b/fboss/cli/fboss2/test/TestableConfigSession.h index 1f2179dcf6c10..30a91251cffd0 100644 --- a/fboss/cli/fboss2/test/TestableConfigSession.h +++ b/fboss/cli/fboss2/test/TestableConfigSession.h @@ -10,6 +10,9 @@ #pragma once +#include +#include + #include "fboss/cli/fboss2/session/ConfigSession.h" namespace facebook::fboss { @@ -19,13 +22,16 @@ namespace facebook::fboss { class TestableConfigSession : public ConfigSession { public: TestableConfigSession( - const std::string& sessionConfigPath, - const std::string& systemConfigPath, - const std::string& cliConfigDir) - : ConfigSession(sessionConfigPath, systemConfigPath, cliConfigDir) {} + std::string sessionConfigDir, + std::string systemConfigDir) + : ConfigSession(std::move(sessionConfigDir), std::move(systemConfigDir)) { + } // Expose protected setInstance() for testing using ConfigSession::setInstance; + + // Expose protected addCommand() for testing + using ConfigSession::addCommand; }; } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/utils/CmdUtils.cpp b/fboss/cli/fboss2/utils/CmdUtils.cpp index 6b78a7cbdda79..8bc53eafdbc6e 100644 --- a/fboss/cli/fboss2/utils/CmdUtils.cpp +++ b/fboss/cli/fboss2/utils/CmdUtils.cpp @@ -17,7 +17,6 @@ #endif #include -#include #include using namespace std::chrono; @@ -516,31 +515,27 @@ RevisionList::RevisionList(std::vector v) { continue; } - // Must be in the form "rN" where N is a positive integer - if (revision.empty() || revision[0] != 'r') { - throw std::invalid_argument( - "Invalid revision specifier: '" + revision + - "'. Expected 'rN' or 'current'"); - } - - // Extract the number part after 'r' - std::string revNum = revision.substr(1); - if (revNum.empty()) { - throw std::invalid_argument( - "Invalid revision specifier: '" + revision + - "'. Expected 'rN' or 'current'"); + // Accept Git commit SHAs (7-40 hex characters) or short refs + // Git SHAs are hexadecimal strings, typically 7-40 characters + bool isValidHex = !revision.empty() && revision.length() >= 7; + if (isValidHex) { + for (char c : revision) { + if (!std::isxdigit(c)) { + isValidHex = false; + break; + } + } } - // Validate that it's all digits - for (char c : revNum) { - if (!std::isdigit(c)) { - throw std::invalid_argument( - "Invalid revision number: '" + revision + - "'. Expected 'rN' or 'current'"); - } + if (isValidHex) { + data_.push_back(revision); + continue; } - data_.push_back(revision); + // If not a valid hex SHA, reject it + throw std::invalid_argument( + "Invalid revision specifier: '" + revision + + "'. Expected a Git commit SHA (7+ hex characters) or 'current'"); } } diff --git a/fboss/cli/fboss2/utils/CmdUtils.h b/fboss/cli/fboss2/utils/CmdUtils.h index 7127f5a0d48e0..5975e6a5ec43b 100644 --- a/fboss/cli/fboss2/utils/CmdUtils.h +++ b/fboss/cli/fboss2/utils/CmdUtils.h @@ -150,9 +150,44 @@ class Message : public BaseObjectArgType { const static ObjectArgTypeId id = ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; }; +// Custom type for VLAN ID argument with validation +class VlanIdValue : public BaseObjectArgType { + public: + /* implicit */ VlanIdValue( // NOLINT(google-explicit-constructor) + std::vector v) { + if (v.empty()) { + throw std::invalid_argument("VLAN ID is required"); + } + if (v.size() != 1) { + throw std::invalid_argument( + "Expected single VLAN ID, got: " + folly::join(", ", v)); + } + + try { + int32_t vlanId = folly::to(v[0]); + // VLAN IDs are typically 1-4094 (0 and 4095 are reserved) + if (vlanId < 1 || vlanId > 4094) { + throw std::invalid_argument( + "VLAN ID must be between 1 and 4094 inclusive, got: " + + std::to_string(vlanId)); + } + data_.push_back(vlanId); + } catch (const folly::ConversionError&) { + throw std::invalid_argument("Invalid VLAN ID: " + v[0]); + } + } + + int32_t getVlanId() const { + return data_[0]; + } + + const static ObjectArgTypeId id = ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID; +}; + class VipInjectorID : public BaseObjectArgType { public: - /* implicit */ VipInjectorID(std::vector v) + /* implicit */ VipInjectorID( // NOLINT(google-explicit-constructor) + std::vector v) : BaseObjectArgType(v) {} const static ObjectArgTypeId id = diff --git a/fboss/cli/fboss2/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 53adaa39a9132..c84d7ec7a3005 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -9,11 +9,19 @@ */ #pragma once -#include +#include +#include #include #include +#include +#include +#include +#include #include +#include +#include #include +#include namespace facebook::fboss::utils { @@ -67,18 +75,35 @@ enum class ObjectArgTypeId : uint8_t { OBJECT_ARG_TYPE_ID_INTERFACE_LIST, OBJECT_ARG_TYPE_ID_REVISION_LIST, OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME, + OBJECT_ARG_TYPE_VLAN_ID, + OBJECT_ARG_TYPE_MAC_AND_PORT, + OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_POLICY_NAME, + OBJECT_ARG_TYPE_ID_PRIORITY_GROUP_ID, + OBJECT_ARG_TYPE_ID_SCALING_FACTOR, + // PFC config argument types + OBJECT_ARG_TYPE_ID_PFC_CONFIG_ATTRS, + // Queuing policy argument types + OBJECT_ARG_TYPE_ID_QUEUING_POLICY_NAME, + OBJECT_ARG_TYPE_ID_QUEUE_ID, + // QoS policy argument types + OBJECT_ARG_TYPE_ID_QOS_POLICY_NAME, + OBJECT_ARG_TYPE_ID_QOS_MAP_ENTRY, + OBJECT_ARG_TYPE_PORT_AND_TAGGING_MODE, + OBJECT_ARG_TYPE_L2_LEARNING_MODE, + OBJECT_ARG_TYPE_ID_INTERFACES_CONFIG, }; template class BaseObjectArgType { public: - BaseObjectArgType() {} - /* implicit */ BaseObjectArgType(std::vector v) : data_(v) {} + BaseObjectArgType() = default; + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ BaseObjectArgType(std::vector v) : data_(std::move(v)) {} using iterator = typename std::vector::iterator; using const_iterator = typename std::vector::const_iterator; using size_type = typename std::vector::size_type; - const std::vector data() const { + std::vector data() const { return data_; } @@ -117,8 +142,9 @@ class BaseObjectArgType { class NoneArgType : public BaseObjectArgType { public: + // NOLINTNEXTLINE(google-explicit-constructor) /* implicit */ NoneArgType(std::vector v) - : BaseObjectArgType(v) {} + : BaseObjectArgType(std::move(v)) {} const static ObjectArgTypeId id = ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; }; @@ -184,7 +210,7 @@ std::vector getHostsFromFile(const std::string& filename); // Common util method timeval splitFractionalSecondsFromTimer(const long& timer); const std::string parseTimeToTimeStamp(const long& timeToParse); -const std::string formatBandwidth(const float bandwidthBytesPerSecond); +const std::string formatBandwidth(float bandwidthBytesPerSecond); long getEpochFromDuration(const int64_t& duration); const std::string getDurationStr(folly::stop_watch<>& watch); const std::string getPrettyElapsedTime(const int64_t& start_time); diff --git a/fboss/cli/fboss2/utils/InterfaceList.cpp b/fboss/cli/fboss2/utils/InterfaceList.cpp index 6c6e4292dffb6..a20d8154d0916 100644 --- a/fboss/cli/fboss2/utils/InterfaceList.cpp +++ b/fboss/cli/fboss2/utils/InterfaceList.cpp @@ -10,6 +10,11 @@ #include "fboss/cli/fboss2/utils/InterfaceList.h" #include +#include +#include +#include +#include +#include "fboss/agent/gen-cpp2/switch_config_types.h" #include "fboss/cli/fboss2/session/ConfigSession.h" #include "fboss/cli/fboss2/utils/PortMap.h" @@ -24,7 +29,7 @@ InterfaceList::InterfaceList(std::vector names) std::vector notFound; for (const auto& name : names_) { - Intf intf; + Intf intf(name); // First try to look up as a port name cfg::Port* port = portMap.getPort(name); diff --git a/fboss/cli/fboss2/utils/InterfaceList.h b/fboss/cli/fboss2/utils/InterfaceList.h index d2b6f2ede565b..ed5000f5850d4 100644 --- a/fboss/cli/fboss2/utils/InterfaceList.h +++ b/fboss/cli/fboss2/utils/InterfaceList.h @@ -10,9 +10,10 @@ #pragma once -#include +#include +#include #include -#include "fboss/agent/if/gen-cpp2/ctrl_types.h" +#include "fboss/agent/gen-cpp2/switch_config_types.h" #include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" namespace facebook::fboss::utils { @@ -23,7 +24,8 @@ namespace facebook::fboss::utils { */ class Intf { public: - Intf() : port_(nullptr), interface_(nullptr) {} + explicit Intf(std::string name) + : name_(std::move(name)), port_(nullptr), interface_(nullptr) {} /* Get the Port pointer (may be nullptr). */ cfg::Port* getPort() const { @@ -45,12 +47,18 @@ class Intf { interface_ = interface; } + /* Get the name of this interface. */ + const std::string& name() const { + return name_; + } + /* Check if this Intf has either a Port or Interface. */ bool isValid() const { return port_ != nullptr || interface_ != nullptr; } private: + std::string name_; cfg::Port* port_; cfg::Interface* interface_; }; @@ -62,6 +70,7 @@ class Intf { */ class InterfaceList : public BaseObjectArgType { public: + // NOLINTNEXTLINE(google-explicit-constructor) /* implicit */ InterfaceList(std::vector names); /* Get the original names provided by the user. */ diff --git a/fboss/cli/fboss2/utils/InterfacesConfig.cpp b/fboss/cli/fboss2/utils/InterfacesConfig.cpp new file mode 100644 index 0000000000000..184dcf44e6b8c --- /dev/null +++ b/fboss/cli/fboss2/utils/InterfacesConfig.cpp @@ -0,0 +1,99 @@ +/* + * 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/InterfacesConfig.h" + +#include +#include +#include + +namespace facebook::fboss::utils { + +namespace { +// Set of known attribute names (lowercase for case-insensitive comparison) +const std::unordered_set kKnownAttributes = { + "description", + "mtu", +}; +} // namespace + +bool InterfacesConfig::isKnownAttribute(const std::string& s) { + std::string lower = s; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + return kKnownAttributes.find(lower) != kKnownAttributes.end(); +} + +InterfacesConfig::InterfacesConfig(std::vector v) + : interfaces_(std::vector{}) { + if (v.empty()) { + throw std::invalid_argument("No interface name provided"); + } + + // Find where port names end and attributes begin + // Ports are all tokens before the first known attribute name + size_t attrStart = v.size(); + for (size_t i = 0; i < v.size(); ++i) { + if (isKnownAttribute(v[i])) { + attrStart = i; + break; + } + } + + // Must have at least one port name + if (attrStart == 0) { + throw std::invalid_argument( + fmt::format( + "No interface name provided. First token '{}' is an attribute name.", + v[0])); + } + + // Extract port names + std::vector portNames(v.begin(), v.begin() + attrStart); + + // Parse attribute-value pairs + for (size_t i = attrStart; i < v.size(); i += 2) { + const std::string& attr = v[i]; + + if (!isKnownAttribute(attr)) { + throw std::invalid_argument( + fmt::format( + "Unknown attribute '{}'. Valid attributes are: description, mtu", + attr)); + } + + if (i + 1 >= v.size()) { + throw std::invalid_argument( + fmt::format("Missing value for attribute '{}'", attr)); + } + + const std::string& value = v[i + 1]; + + // Check if "value" is actually another attribute name (user forgot value) + if (isKnownAttribute(value)) { + throw std::invalid_argument( + fmt::format( + "Missing value for attribute '{}'. Got another attribute '{}' instead.", + attr, + value)); + } + + // Normalize attribute name to lowercase + std::string attrLower = attr; + std::transform( + attrLower.begin(), attrLower.end(), attrLower.begin(), ::tolower); + attributes_.emplace_back(attrLower, value); + } + + // Now resolve the port names to InterfaceList + // This will throw if any port is not found + interfaces_ = InterfaceList(std::move(portNames)); +} + +} // namespace facebook::fboss::utils diff --git a/fboss/cli/fboss2/utils/InterfacesConfig.h b/fboss/cli/fboss2/utils/InterfacesConfig.h new file mode 100644 index 0000000000000..328482fe681d4 --- /dev/null +++ b/fboss/cli/fboss2/utils/InterfacesConfig.h @@ -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. + * + */ + +#pragma once + +#include +#include +#include +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/InterfaceList.h" + +namespace facebook::fboss::utils { + +/* + * InterfacesConfig captures both port/interface names and optional + * attribute key-value pairs from the CLI. + * + * Usage: config interface [ ...] + * + * The first tokens (until a known attribute name is encountered) are + * treated as port/interface names. The remaining tokens are parsed + * as attribute-value pairs. + * + * Supported attributes: description, mtu + */ +class InterfacesConfig : public BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ InterfacesConfig(std::vector v); + + /* Get the resolved interfaces. */ + const InterfaceList& getInterfaces() const { + return interfaces_; + } + + /* Get the attribute-value pairs. */ + const std::vector>& getAttributes() + const { + return attributes_; + } + + /* Check if any attributes were provided. */ + bool hasAttributes() const { + return !attributes_.empty(); + } + + const static ObjectArgTypeId id = + ObjectArgTypeId::OBJECT_ARG_TYPE_ID_INTERFACES_CONFIG; + + private: + InterfaceList interfaces_; + std::vector> attributes_; + + // Check if a string is a known attribute name + static bool isKnownAttribute(const std::string& s); +}; + +} // namespace facebook::fboss::utils diff --git a/fboss/cli/test/BUCK b/fboss/cli/test/BUCK new file mode 100644 index 0000000000000..5e325280e7cfa --- /dev/null +++ b/fboss/cli/test/BUCK @@ -0,0 +1,42 @@ +load("@fbcode_macros//build_defs:cpp_binary.bzl", "cpp_binary") + +oncall("fboss_agent_push") + +# CLI E2E test binary - End-to-end tests for CLI commands +# +# These tests run against a live FBOSS agent and execute actual CLI commands. +# Unlike unit tests, they require the agent to be running with valid config. +# They are equivalent to the Python tests in fboss/oss/cli_tests/. +# +# CLI tests are platform/SAI independent - they test the CLI binary which +# communicates with the agent via Thrift, regardless of the underlying +# hardware abstraction layer. +# +# Unlike the Python-based CLI tests, these C++ tests directly invoke the CLI +# library code rather than spawning a subprocess. +cpp_binary( + name = "cli_test", + srcs = [ + "CliTest.cpp", + "ConfigInterfaceDescriptionTest.cpp", + "ConfigInterfaceMtuTest.cpp", + "ConfigQosPolicyMapTest.cpp", + "ConfigSessionClearTest.cpp", + ], + deps = [ + "fbsource//third-party/googletest:gtest", + "//fboss/cli/fboss2:cmd-global-options", + "//fboss/cli/fboss2:cmd-list-header", + "//fboss/cli/fboss2:cmd-subcommands", + "//folly:dynamic", + "//folly:format", + "//folly:subprocess", + "//folly/json:dynamic", + "//folly/logging:init", + "//folly/logging:logging", + ], + external_deps = [ + "CLI11", + "gflags", + ], +) diff --git a/fboss/cli/test/CliTest.cpp b/fboss/cli/test/CliTest.cpp new file mode 100644 index 0000000000000..bbe1fa66a7d76 --- /dev/null +++ b/fboss/cli/test/CliTest.cpp @@ -0,0 +1,274 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +#include "fboss/cli/test/CliTest.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/cli/fboss2/CliAppInit.h" + +namespace fs = std::filesystem; + +namespace facebook::fboss { + +void CliTest::SetUp() { + XLOG(INFO) << "CliTest::SetUp - starting CLI test"; + // Discard any stale session from previous runs to ensure we start fresh + discardSession(); +} + +void CliTest::TearDown() { + XLOG(INFO) << "CliTest::TearDown - cleaning up CLI test"; +} + +void CliTest::discardSession() const { + // Delete the session files to ensure we start with a fresh session + // based on the current HEAD. ConfigSession::initializeSession() will + // reset internal state when it detects no session file exists. + // NOLINTNEXTLINE(concurrency-mt-unsafe): HOME is read-only in practice + const char* home = std::getenv("HOME"); + if (home == nullptr) { + XLOG(WARN) << "HOME environment variable not set, cannot discard session"; + return; + } + fs::path sessionDir = fs::path(home) / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path sessionMetadata = sessionDir / "cli_metadata.json"; + + std::error_code ec; + if (fs::exists(sessionConfig, ec)) { + XLOG(INFO) << "Discarding session config: " << sessionConfig.string(); + fs::remove(sessionConfig, ec); + if (ec) { + XLOG(WARN) << "Failed to remove session config: " << ec.message(); + } + } + if (fs::exists(sessionMetadata, ec)) { + XLOG(INFO) << "Discarding session metadata: " << sessionMetadata.string(); + fs::remove(sessionMetadata, ec); + if (ec) { + XLOG(WARN) << "Failed to remove session metadata: " << ec.message(); + } + } +} + +CliResult CliTest::executeCliCommand( + const std::vector& args) const { + // Create a new CLI::App for this command + CLI::App app{"FBOSS CLI Test"}; + utils::initApp(app); + + // Build argv-style argument list + // Prepend program name and --fmt json + std::vector fullArgs = {"cli_test", "--fmt", "json"}; + fullArgs.insert(fullArgs.end(), args.begin(), args.end()); + + // Convert to argc/argv format + std::vector argv; + argv.reserve(fullArgs.size()); + for (auto& arg : fullArgs) { + argv.push_back(arg.data()); + } + + // Redirect stdout and stderr to capture output + std::stringstream capturedStdout; + std::stringstream capturedStderr; + std::streambuf* oldStdout = std::cout.rdbuf(capturedStdout.rdbuf()); + std::streambuf* oldStderr = std::cerr.rdbuf(capturedStderr.rdbuf()); + + int exitCode = 0; + try { + // Parse and execute the command + app.parse(static_cast(argv.size()), argv.data()); + } catch (const CLI::ParseError& e) { + exitCode = app.exit(e); + } catch (const std::exception& e) { + capturedStderr << "Error: " << e.what() << "\n"; + exitCode = 1; + } + + // Restore original stdout/stderr + std::cout.rdbuf(oldStdout); + std::cerr.rdbuf(oldStderr); + + return CliResult{exitCode, capturedStdout.str(), capturedStderr.str()}; +} + +CliResult CliTest::runCli(const std::vector& args) const { + XLOG(INFO) << "Running CLI command: " << folly::join(" ", args); + return executeCliCommand(args); +} + +folly::dynamic CliTest::runCliJson(const std::vector& args) const { + auto result = runCli(args); + if (result.exitCode != 0) { + throw std::runtime_error( + fmt::format( + "CLI command failed with exit code {}: {}", + result.exitCode, + result.stderr)); + } + if (result.stdout.empty()) { + return folly::dynamic::object(); + } + return folly::parseJson(result.stdout); +} + +CliResult CliTest::runCmd(const std::vector& args) const { + XLOG(INFO) << "Running command: " << folly::join(" ", args); + + folly::Subprocess::Options options; + options.pipeStdout(); + options.pipeStderr(); + + folly::Subprocess proc(args, options); + auto [stdout, stderr] = proc.communicate(); + auto exitStatus = proc.wait().exitStatus(); + + return CliResult{exitStatus, stdout, stderr}; +} + +Interface CliTest::parseInterfaceJson(const folly::dynamic& data) const { + Interface intf; + intf.name = data["name"].asString(); + intf.status = data["status"].asString(); + intf.speed = data["speed"].asString(); + if (data.count("vlan") && !data["vlan"].isNull()) { + intf.vlan = data["vlan"].asInt(); + } + intf.mtu = data["mtu"].asInt(); + + // Parse prefixes + if (data.count("prefixes")) { + for (const auto& prefix : data["prefixes"]) { + intf.addresses.push_back( + fmt::format( + "{}/{}", + prefix["ip"].asString(), + prefix["prefixLength"].asInt())); + } + } + + intf.description = data.getDefault("description", "").asString(); + return intf; +} + +std::map CliTest::getAllInterfaces() const { + auto json = runCliJson({"show", "interface"}); + std::map interfaces; + + // JSON has a host key containing the interfaces + for (const auto& [host, hostData] : json.items()) { + if (!hostData.isObject() || !hostData.count("interfaces")) { + continue; + } + for (const auto& intfData : hostData["interfaces"]) { + auto intf = parseInterfaceJson(intfData); + interfaces[intf.name] = intf; + } + } + + return interfaces; +} + +Interface CliTest::getInterfaceInfo(const std::string& interfaceName) const { + auto json = runCliJson({"show", "interface", interfaceName}); + + XLOG(DBG2) << "getInterfaceInfo JSON: " << folly::toPrettyJson(json); + + for (const auto& [host, hostData] : json.items()) { + XLOG(DBG2) << " Host: " << host << ", isObject: " << hostData.isObject() + << ", hasInterfaces: " << hostData.count("interfaces"); + if (!hostData.isObject() || !hostData.count("interfaces")) { + continue; + } + for (const auto& intfData : hostData["interfaces"]) { + XLOG(DBG2) << " Interface: " << intfData["name"].asString(); + if (intfData["name"].asString() == interfaceName) { + return parseInterfaceJson(intfData); + } + } + } + + throw std::runtime_error( + fmt::format("Interface {} not found", interfaceName)); +} + +Interface CliTest::findFirstEthInterface() const { + auto interfaces = getAllInterfaces(); + + for (const auto& [name, intf] : interfaces) { + if (name.rfind("eth", 0) == 0 && intf.vlan.has_value() && *intf.vlan > 1) { + return intf; + } + } + + throw std::runtime_error( + "No suitable ethernet interface found with VLAN > 1"); +} + +void CliTest::commitConfig() const { + auto result = runCli({"config", "session", "commit"}); + ASSERT_EQ(result.exitCode, 0) << "Failed to commit config: " << result.stderr; +} + +int CliTest::getKernelInterfaceMtu(int vlanId) const { + auto kernelIntf = fmt::format("fboss{}", vlanId); + // Use full path to ip command since folly::Subprocess doesn't search PATH + auto result = runCmd({"/usr/sbin/ip", "-json", "link", "show", kernelIntf}); + + if (result.exitCode != 0) { + XLOG(WARN) << "Kernel interface " << kernelIntf << " not found"; + return -1; + } + + auto json = folly::parseJson(result.stdout); + if (json.empty()) { + throw std::runtime_error( + fmt::format("No data returned for kernel interface {}", kernelIntf)); + } + + return json[0]["mtu"].asInt(); +} + +int cliTestMain(int argc, char** argv) { + // Initialize gtest first so it consumes --gtest_* flags before folly::init + ::testing::InitGoogleTest(&argc, argv); + + // Initialize folly singletons before using CLI components + folly::init(&argc, &argv, /* removeFlags = */ false); + + return RUN_ALL_TESTS(); +} + +} // namespace facebook::fboss + +#ifdef IS_OSS +FOLLY_INIT_LOGGING_CONFIG("DBG2; default:async=true"); +#else +FOLLY_INIT_LOGGING_CONFIG("fboss=DBG2; default:async=true"); +#endif + +int main(int argc, char* argv[]) { + return facebook::fboss::cliTestMain(argc, argv); +} diff --git a/fboss/cli/test/CliTest.h b/fboss/cli/test/CliTest.h new file mode 100644 index 0000000000000..8a747415a53ed --- /dev/null +++ b/fboss/cli/test/CliTest.h @@ -0,0 +1,133 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::fboss { + +/** + * Represents the result of a CLI command execution. + */ +struct CliResult { + int exitCode; + std::string stdout; + std::string stderr; +}; + +/** + * Represents a network interface from the output of 'show interface'. + */ +struct Interface { + std::string name; + std::string status; + std::string speed; + std::optional vlan; + int mtu; + std::vector addresses; + std::string description; +}; + +/** + * CliTest is the base class for CLI end-to-end tests. + * + * Unlike the link tests, CLI tests don't need to be concerned with the SAI + * or what SAI we're using, since we're testing the CLI and the CLI is + * largely platform independent. + * + * The tests run against a live FBOSS agent and execute actual CLI commands + * by directly invoking the CLI library code (not spawning a subprocess). + */ +class CliTest : public ::testing::Test { + public: + ~CliTest() override = default; + + protected: + void SetUp() override; + void TearDown() override; + + /** + * Run a CLI command with the given arguments. + * The --fmt json flag is automatically prepended. + * @param args The command arguments (e.g., {"show", "interface"}) + * @return CliResult with exit code, stdout, and stderr + */ + CliResult runCli(const std::vector& args) const; + + /** + * Run a CLI command and parse the JSON output. + * Throws if the command fails or output is not valid JSON. + * @param args The command arguments + * @return Parsed JSON as folly::dynamic + */ + folly::dynamic runCliJson(const std::vector& args) const; + + /** + * Run a shell command and return the result. + * @param args Command and arguments + * @return CliResult with exit code, stdout, and stderr + */ + CliResult runCmd(const std::vector& args) const; + + /** + * Get information about a specific interface. + * @param interfaceName The interface name (e.g., "eth1/1/1") + * @return Interface object with the interface details + */ + Interface getInterfaceInfo(const std::string& interfaceName) const; + + /** + * Get all interfaces from the system. + * @return Map of interface name to Interface object + */ + std::map getAllInterfaces() const; + + /** + * Find the first suitable ethernet interface for testing. + * Only returns ethernet interfaces (starting with 'eth') with a valid VLAN. + * @return Interface object + */ + Interface findFirstEthInterface() const; + + /** + * Commit the current configuration session. + */ + void commitConfig() const; + + /** + * Get the MTU of a kernel interface using 'ip -json link'. + * @param vlanId The VLAN ID (interface name will be fboss) + * @return MTU value, or -1 if interface not found + */ + int getKernelInterfaceMtu(int vlanId) const; + + /** + * Discard any pending config session by deleting session files. + * This ensures each test starts with a fresh session based on current HEAD. + */ + void discardSession() const; + + private: + Interface parseInterfaceJson(const folly::dynamic& data) const; + + /** + * Execute a CLI command by parsing args and running the command. + * Captures stdout/stderr and returns the result. + */ + CliResult executeCliCommand(const std::vector& args) const; +}; + +/** + * Main function for CLI tests. + * Initializes gtest and runs all CLI tests. + */ +int cliTestMain(int argc, char** argv); + +} // namespace facebook::fboss diff --git a/fboss/cli/test/ConfigInterfaceDescriptionTest.cpp b/fboss/cli/test/ConfigInterfaceDescriptionTest.cpp new file mode 100644 index 0000000000000..148c8a5dd9dbb --- /dev/null +++ b/fboss/cli/test/ConfigInterfaceDescriptionTest.cpp @@ -0,0 +1,85 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +/** + * End-to-end test for 'fboss2-dev config interface description ' + * + * 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) + */ + +#include +#include +#include +#include "fboss/cli/test/CliTest.h" + +using namespace facebook::fboss; + +class ConfigInterfaceDescriptionTest : public CliTest { + protected: + void setInterfaceDescription( + const std::string& interfaceName, + const std::string& description) { + auto result = runCli( + {"config", "interface", interfaceName, "description", description}); + ASSERT_EQ(result.exitCode, 0) + << "Failed to set description: " << result.stderr; + commitConfig(); + } +}; + +TEST_F(ConfigInterfaceDescriptionTest, SetAndVerifyDescription) { + // Step 1: Find an interface to test with + XLOG(INFO) << "[Step 1] Finding an interface to test..."; + Interface interface = findFirstEthInterface(); + XLOG(INFO) << " Using interface: " << interface.name << " (VLAN: " + << (interface.vlan.has_value() ? std::to_string(*interface.vlan) + : "none") + << ")"; + + // Step 2: Get the current description + XLOG(INFO) << "[Step 2] Getting current description..."; + std::string originalDescription = interface.description; + XLOG(INFO) << " Current description: '" << originalDescription << "'"; + + // Step 3: Set a new description + std::string testDescription = "CLI_E2E_TEST_DESCRIPTION"; + if (originalDescription == testDescription) { + testDescription = "CLI_E2E_TEST_DESCRIPTION_ALT"; + } + XLOG(INFO) << "[Step 3] Setting description to '" << testDescription + << "'..."; + setInterfaceDescription(interface.name, testDescription); + XLOG(INFO) << " Description set to '" << testDescription << "'"; + + // Step 4: Verify description via 'show interface' + XLOG(INFO) << "[Step 4] Verifying description via 'show interface'..."; + Interface updatedInterface = getInterfaceInfo(interface.name); + EXPECT_EQ(updatedInterface.description, testDescription) + << "Expected description '" << testDescription << "', got '" + << updatedInterface.description << "'"; + XLOG(INFO) << " Verified: Description is '" << updatedInterface.description + << "'"; + + // Step 5: Restore original description + XLOG(INFO) << "[Step 5] Restoring original description ('" + << originalDescription << "')..."; + setInterfaceDescription(interface.name, originalDescription); + XLOG(INFO) << " Restored description to '" << originalDescription << "'"; + + // Verify restoration + Interface restoredInterface = getInterfaceInfo(interface.name); + if (restoredInterface.description != originalDescription) { + XLOG(WARN) << " WARNING: Restoration may have failed. Current: '" + << restoredInterface.description << "'"; + } + + XLOG(INFO) << "TEST PASSED"; +} diff --git a/fboss/cli/test/ConfigInterfaceMtuTest.cpp b/fboss/cli/test/ConfigInterfaceMtuTest.cpp new file mode 100644 index 0000000000000..c48791aa4f318 --- /dev/null +++ b/fboss/cli/test/ConfigInterfaceMtuTest.cpp @@ -0,0 +1,83 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +/** + * 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) + */ + +#include +#include +#include +#include "fboss/cli/test/CliTest.h" + +using namespace facebook::fboss; + +class ConfigInterfaceMtuTest : public CliTest { + protected: + void setInterfaceMtu(const std::string& interfaceName, int mtu) { + auto result = runCli( + {"config", "interface", interfaceName, "mtu", std::to_string(mtu)}); + ASSERT_EQ(result.exitCode, 0) << "Failed to set MTU: " << result.stderr; + commitConfig(); + } +}; + +TEST_F(ConfigInterfaceMtuTest, SetAndVerifyMtu) { + // Step 1: Find an interface to test with + XLOG(INFO) << "[Step 1] Finding an interface to test..."; + Interface interface = findFirstEthInterface(); + XLOG(INFO) << " Using interface: " << interface.name << " (VLAN: " + << (interface.vlan.has_value() ? std::to_string(*interface.vlan) + : "none") + << ")"; + + // Step 2: Get the current MTU + XLOG(INFO) << "[Step 2] Getting current MTU..."; + int originalMtu = interface.mtu; + XLOG(INFO) << " Current MTU: " << originalMtu; + + // Step 3: Set a new MTU (toggle between 1500 and 9000) + int newMtu = (originalMtu != 9000) ? 9000 : 1500; + XLOG(INFO) << "[Step 3] Setting MTU to " << newMtu << "..."; + setInterfaceMtu(interface.name, newMtu); + XLOG(INFO) << " MTU set to " << newMtu; + + // Step 4: Verify MTU via 'show interface' + XLOG(INFO) << "[Step 4] Verifying MTU via 'show interface'..."; + Interface updatedInterface = getInterfaceInfo(interface.name); + EXPECT_EQ(updatedInterface.mtu, newMtu) + << "Expected MTU " << newMtu << ", got " << updatedInterface.mtu; + XLOG(INFO) << " Verified: MTU is " << updatedInterface.mtu; + + // Step 5: Verify kernel interface MTU + XLOG(INFO) << "[Step 5] Verifying kernel interface MTU..."; + ASSERT_TRUE(interface.vlan.has_value()); + int kernelMtu = getKernelInterfaceMtu(*interface.vlan); + if (kernelMtu > 0) { + EXPECT_EQ(kernelMtu, newMtu) + << "Kernel MTU is " << kernelMtu << ", expected " << newMtu; + XLOG(INFO) << " Verified: Kernel interface fboss" << *interface.vlan + << " has MTU " << kernelMtu; + } else { + XLOG(INFO) << " Skipped: Kernel interface fboss" << *interface.vlan + << " not found"; + } + + // Step 6: Restore original MTU + XLOG(INFO) << "[Step 6] Restoring original MTU (" << originalMtu << ")..."; + setInterfaceMtu(interface.name, originalMtu); + XLOG(INFO) << " Restored MTU to " << originalMtu; + + XLOG(INFO) << "TEST PASSED"; +} diff --git a/fboss/cli/test/ConfigQosPolicyMapTest.cpp b/fboss/cli/test/ConfigQosPolicyMapTest.cpp new file mode 100644 index 0000000000000..f560a86703e6d --- /dev/null +++ b/fboss/cli/test/ConfigQosPolicyMapTest.cpp @@ -0,0 +1,349 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +/** + * End-to-end test for QoS policy map CLI commands: + * fboss2-dev config qos policy map ... + * + * This test creates a sample QoS policy configuring: + * - DOT1P/PCP maps (both ingress and egress) + * - trafficClassToQueueId mappings + * + * The test: + * 1. Creates a test QoS policy + * 2. Configures pcpMaps similar to sample config + * 3. Configures tc-to-queue mappings + * 4. Commits the config + * 5. Verifies the config was applied by querying the agent's running config + * 6. Cleans up by removing the test policy + * + * Requirements: + * - FBOSS agent must be running with valid configuration + * - The test must be run as root (or with appropriate permissions) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "fboss/agent/if/gen-cpp2/FbossCtrlAsyncClient.h" +#include "fboss/cli/fboss2/utils/CmdClientUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/test/CliTest.h" + +using namespace facebook::fboss; + +namespace { +// Generate a unique policy name using a timestamp to avoid conflicts with +// stale data from previous test runs +std::string generateTestPolicyName() { + auto now = std::chrono::system_clock::now(); + auto epochMs = std::chrono::duration_cast( + now.time_since_epoch()) + .count(); + return fmt::format("cli_e2e_test_qos_policy_{}", epochMs); +} +} // namespace + +class ConfigQosPolicyMapTest : public CliTest { + protected: + // Policy name is generated once per test instance + std::string testPolicyName_ = generateTestPolicyName(); + + void SetUp() override { + CliTest::SetUp(); + XLOG(INFO) << "Using test policy name: " << testPolicyName_; + // Clean up any existing test policy from a previous failed run + cleanupTestPolicy(); + } + + void TearDown() override { + // Clean up test policy + cleanupTestPolicy(); + CliTest::TearDown(); + } + + void cleanupTestPolicy() { + // NOTE: We don't have a 'delete' command for QoS policies yet. + // For now, just discard any pending session. The test policy may + // remain in the config after a successful run, but that's OK for + // this test - it uses a unique name so won't conflict with previous runs. + discardSession(); + } + + /** + * Configure a list map (DSCP/EXP/DOT1P) with 4-token syntax: + * + */ + void configureMap( + const std::string& mapType1, + int value1, + const std::string& mapType2, + int value2) { + auto result = runCli( + {"config", + "qos", + "policy", + testPolicyName_, + "map", + mapType1, + std::to_string(value1), + mapType2, + std::to_string(value2)}); + // Log the command output for debugging (use DBG1 to reduce noise) + XLOG(DBG1) << "Command: " << mapType1 << " " << value1 << " " << mapType2 + << " " << value2; + XLOG(DBG1) << "stdout: " << result.stdout; + if (!result.stderr.empty()) { + XLOG(DBG1) << "stderr: " << result.stderr; + } + ASSERT_EQ(result.exitCode, 0) + << "Failed to set map " << mapType1 << " " << value1 << " " << mapType2 + << " " << value2 << ": " << result.stderr; + } + + /** + * Configure a simple map (tc-to-queue, pfc-pri-to-queue, etc) with 3-token + * syntax: + */ + void configureSimpleMap(const std::string& mapType, int key, int value) { + auto result = runCli( + {"config", + "qos", + "policy", + testPolicyName_, + "map", + mapType, + std::to_string(key), + std::to_string(value)}); + // Log the command output for debugging (use DBG1 to reduce noise) + XLOG(DBG1) << "Command: " << mapType << " " << key << " " << value; + XLOG(DBG1) << "stdout: " << result.stdout; + if (!result.stderr.empty()) { + XLOG(DBG1) << "stderr: " << result.stderr; + } + ASSERT_EQ(result.exitCode, 0) + << "Failed to set simple map " << mapType << " " << key << " " << value + << ": " << result.stderr; + } + + /** + * Get the running config from the agent and return it as a parsed JSON + * object. + */ + folly::dynamic getRunningConfig() const { + HostInfo hostInfo("localhost"); + auto client = + utils::createClient>(hostInfo); + std::string configStr; + client->sync_getRunningConfig(configStr); + return folly::parseJson(configStr); + } + + /** + * Find a QoS policy by name in the running config. + * Returns nullptr if not found. + */ + const folly::dynamic* findQosPolicy( + const folly::dynamic& config, + const std::string& policyName) const { + if (!config.isObject() || !config.count("sw")) { + return nullptr; + } + const auto& sw = config["sw"]; + if (!sw.isObject() || !sw.count("qosPolicies")) { + return nullptr; + } + const auto& policies = sw["qosPolicies"]; + if (!policies.isArray()) { + return nullptr; + } + for (const auto& policy : policies) { + if (policy.isObject() && policy.count("name") && + policy["name"].asString() == policyName) { + return &policy; + } + } + return nullptr; + } + + /** + * Verify that a pcpMap entry has the expected values. + */ + void verifyPcpMapEntry( + const folly::dynamic& pcpMaps, + int16_t trafficClass, + const std::vector& expectedIngress, + std::optional expectedEgress) const { + // Find the entry with the given traffic class + const folly::dynamic* entry = nullptr; + for (const auto& e : pcpMaps) { + if (e.isObject() && e.count("internalTrafficClass") && + e["internalTrafficClass"].asInt() == trafficClass) { + entry = &e; + break; + } + } + ASSERT_NE(entry, nullptr) + << "pcpMap entry for TC " << trafficClass << " not found"; + + // Verify ingress list + ASSERT_TRUE(entry->count("fromPcpToTrafficClass")) + << "fromPcpToTrafficClass missing for TC " << trafficClass; + const auto& ingressList = (*entry)["fromPcpToTrafficClass"]; + ASSERT_TRUE(ingressList.isArray()); + ASSERT_EQ(ingressList.size(), expectedIngress.size()) + << "Wrong ingress list size for TC " << trafficClass; + for (const auto& expectedVal : expectedIngress) { + // The ingress list may not be in order, so check if value exists + bool found = false; + for (const auto& v : ingressList) { + if (v.asInt() == expectedVal) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "Expected ingress value " + << static_cast(expectedVal) + << " not found for TC " << trafficClass; + } + + // Verify egress value + if (expectedEgress.has_value()) { + ASSERT_TRUE(entry->count("fromTrafficClassToPcp")) + << "fromTrafficClassToPcp missing for TC " << trafficClass; + EXPECT_EQ((*entry)["fromTrafficClassToPcp"].asInt(), *expectedEgress) + << "Wrong egress value for TC " << trafficClass; + } + } +}; + +TEST_F(ConfigQosPolicyMapTest, CreateSamplePolicy) { + XLOG(INFO) << "========================================"; + XLOG(INFO) << "ConfigQosPolicyMapTest::CreateSamplePolicy"; + XLOG(INFO) << "========================================"; + + // Step 1: Configure pcpMaps with a sample configuration: + // - TC 0: pcp 0,2 -> TC 0, TC 0 -> pcp 0 + // - TC 1: pcp 3 -> TC 1, TC 1 -> pcp 3 + // - TC 2: pcp 1 -> TC 2, TC 2 -> pcp 1 + // - TC 4: pcp 4,5 -> TC 4, TC 4 -> pcp 4 + // - TC 6: pcp 6 -> TC 6, TC 6 -> pcp 6 + // - TC 7: pcp 7 -> TC 7, TC 7 -> pcp 7 + + XLOG(INFO) << "[Step 1] Configuring DOT1P/PCP maps..."; + + // TC 0: ingress pcp 0,2 -> TC 0 + configureMap("dot1p", 0, "traffic-class", 0); + configureMap("dot1p", 2, "traffic-class", 0); + // TC 0: egress TC 0 -> pcp 0 + configureMap("traffic-class", 0, "dot1p", 0); + + // TC 1: ingress pcp 3 -> TC 1 + configureMap("dot1p", 3, "traffic-class", 1); + // TC 1: egress TC 1 -> pcp 3 + configureMap("traffic-class", 1, "dot1p", 3); + + // TC 2: ingress pcp 1 -> TC 2 + configureMap("dot1p", 1, "traffic-class", 2); + // TC 2: egress TC 2 -> pcp 1 + configureMap("traffic-class", 2, "dot1p", 1); + + // TC 4: ingress pcp 4,5 -> TC 4 + configureMap("dot1p", 4, "traffic-class", 4); + configureMap("dot1p", 5, "traffic-class", 4); + // TC 4: egress TC 4 -> pcp 4 + configureMap("traffic-class", 4, "dot1p", 4); + + // TC 6: ingress pcp 6 -> TC 6 + configureMap("dot1p", 6, "traffic-class", 6); + // TC 6: egress TC 6 -> pcp 6 + configureMap("traffic-class", 6, "dot1p", 6); + + // TC 7: ingress pcp 7 -> TC 7 + configureMap("dot1p", 7, "traffic-class", 7); + // TC 7: egress TC 7 -> pcp 7 + configureMap("traffic-class", 7, "dot1p", 7); + + XLOG(INFO) << " DOT1P maps configured"; + + // Step 2: Configure trafficClassToQueueId mappings using tc-to-queue syntax + XLOG(INFO) << "[Step 2] Configuring traffic-class-to-queue mappings..."; + configureSimpleMap("tc-to-queue", 0, 0); + configureSimpleMap("tc-to-queue", 1, 1); + configureSimpleMap("tc-to-queue", 2, 2); + configureSimpleMap("tc-to-queue", 4, 4); + configureSimpleMap("tc-to-queue", 6, 6); + configureSimpleMap("tc-to-queue", 7, 7); + XLOG(INFO) << " TC-to-queue mappings configured"; + + // Step 3: Commit the config + XLOG(INFO) << "[Step 3] Committing config..."; + commitConfig(); + XLOG(INFO) << " Config committed"; + + // Step 4: Verify config by querying the agent's running config + XLOG(INFO) << "[Step 4] Verifying config was applied..."; + + auto config = getRunningConfig(); + XLOG(INFO) << " Got running config from agent"; + + // Find the test policy + const auto* policy = findQosPolicy(config, testPolicyName_); + ASSERT_NE(policy, nullptr) + << "Test policy '" << testPolicyName_ << "' not found in running config"; + XLOG(INFO) << " Found test policy in running config"; + + // Verify qosMap exists + ASSERT_TRUE(policy->count("qosMap")) << "qosMap not found in test policy"; + const auto& qosMap = (*policy)["qosMap"]; + + // Verify pcpMaps + ASSERT_TRUE(qosMap.count("pcpMaps")) << "pcpMaps not found in qosMap"; + const auto& pcpMaps = qosMap["pcpMaps"]; + ASSERT_TRUE(pcpMaps.isArray()) << "pcpMaps is not an array"; + ASSERT_EQ(pcpMaps.size(), 6) << "Expected 6 pcpMap entries"; + XLOG(INFO) << " Verified pcpMaps has 6 entries"; + + // Verify each pcpMap entry + // TC 0: ingress pcp 0,2 -> TC 0, egress TC 0 -> pcp 0 + verifyPcpMapEntry(pcpMaps, 0, {0, 2}, 0); + // TC 1: ingress pcp 3 -> TC 1, egress TC 1 -> pcp 3 + verifyPcpMapEntry(pcpMaps, 1, {3}, 3); + // TC 2: ingress pcp 1 -> TC 2, egress TC 2 -> pcp 1 + verifyPcpMapEntry(pcpMaps, 2, {1}, 1); + // TC 4: ingress pcp 4,5 -> TC 4, egress TC 4 -> pcp 4 + verifyPcpMapEntry(pcpMaps, 4, {4, 5}, 4); + // TC 6: ingress pcp 6 -> TC 6, egress TC 6 -> pcp 6 + verifyPcpMapEntry(pcpMaps, 6, {6}, 6); + // TC 7: ingress pcp 7 -> TC 7, egress TC 7 -> pcp 7 + verifyPcpMapEntry(pcpMaps, 7, {7}, 7); + XLOG(INFO) << " All pcpMap entries verified"; + + // Verify trafficClassToQueueId + ASSERT_TRUE(qosMap.count("trafficClassToQueueId")) + << "trafficClassToQueueId not found in qosMap"; + const auto& tcToQueue = qosMap["trafficClassToQueueId"]; + ASSERT_TRUE(tcToQueue.isObject()) << "trafficClassToQueueId is not an object"; + + // Check expected TC-to-queue mappings + // The JSON keys are strings (e.g. "0", "1", etc.) + std::map expectedMappings = { + {"0", 0}, {"1", 1}, {"2", 2}, {"4", 4}, {"6", 6}, {"7", 7}}; + for (const auto& [tc, queue] : expectedMappings) { + ASSERT_TRUE(tcToQueue.count(tc)) + << "TC " << tc << " not found in trafficClassToQueueId"; + EXPECT_EQ(tcToQueue[tc].asInt(), queue) + << "TC " << tc << " has wrong queue mapping"; + } + XLOG(INFO) << " trafficClassToQueueId verified"; + + XLOG(INFO) << "TEST PASSED"; +} diff --git a/fboss/cli/test/ConfigSessionClearTest.cpp b/fboss/cli/test/ConfigSessionClearTest.cpp new file mode 100644 index 0000000000000..6a6c6d4697ba3 --- /dev/null +++ b/fboss/cli/test/ConfigSessionClearTest.cpp @@ -0,0 +1,114 @@ +// (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary. + +/** + * End-to-end test for 'fboss2-dev config session clear' + * + * This test: + * 1. Creates a config session by making a config change + * 2. Verifies the session exists + * 3. Clears the session using 'config session clear' + * 4. Verifies the session was cleared + * + * Requirements: + * - FBOSS agent must be running with a valid configuration + * - The test must be run as root (or with appropriate permissions) + */ + +#include +#include +#include +#include +#include "fboss/cli/test/CliTest.h" + +namespace fs = std::filesystem; + +using namespace facebook::fboss; + +class ConfigSessionClearTest : public CliTest { + protected: + fs::path getSessionConfigPath() const { + // NOLINTNEXTLINE(concurrency-mt-unsafe): HOME is read-only in practice + const char* home = std::getenv("HOME"); + if (home == nullptr) { + throw std::runtime_error("HOME environment variable not set"); + } + return fs::path(home) / ".fboss2" / "agent.conf"; + } + + fs::path getSessionMetadataPath() const { + // NOLINTNEXTLINE(concurrency-mt-unsafe): HOME is read-only in practice + const char* home = std::getenv("HOME"); + if (home == nullptr) { + throw std::runtime_error("HOME environment variable not set"); + } + return fs::path(home) / ".fboss2" / "cli_metadata.json"; + } + + bool sessionExists() const { + return fs::exists(getSessionConfigPath()); + } +}; + +TEST_F(ConfigSessionClearTest, CreateAndClearSession) { + // Step 1: Find an interface to make a config change + XLOG(INFO) << "[Step 1] Finding an interface to create a session..."; + Interface interface = findFirstEthInterface(); + XLOG(INFO) << " Using interface: " << interface.name; + + // Step 2: Make a config change to create a session + XLOG(INFO) << "[Step 2] Making a config change to create a session..."; + std::string testDescription = "CLI_E2E_SESSION_CLEAR_TEST"; + auto result = runCli( + {"config", "interface", interface.name, "description", testDescription}); + ASSERT_EQ(result.exitCode, 0) + << "Failed to set description: " << result.stderr; + XLOG(INFO) << " Config change made successfully"; + + // Step 3: Verify session files exist + XLOG(INFO) << "[Step 3] Verifying session exists..."; + EXPECT_TRUE(sessionExists()) << "Session config file should exist"; + XLOG(INFO) << " Session exists: " << getSessionConfigPath().string(); + + // Step 4: Clear the session + XLOG(INFO) << "[Step 4] Clearing the session..."; + result = runCli({"config", "session", "clear"}); + ASSERT_EQ(result.exitCode, 0) << "Failed to clear session: " << result.stderr; + XLOG(INFO) << " Session cleared successfully"; + + // Step 5: Verify session files are removed + XLOG(INFO) << "[Step 5] Verifying session was cleared..."; + EXPECT_FALSE(sessionExists()) << "Session config file should be removed"; + EXPECT_FALSE(fs::exists(getSessionMetadataPath())) + << "Session metadata file should be removed"; + XLOG(INFO) << " Session files removed"; + + XLOG(INFO) << "TEST PASSED"; +} + +TEST_F(ConfigSessionClearTest, ClearNonExistentSession) { + // Step 1: Ensure no session exists by clearing any existing session + XLOG(INFO) << "[Step 1] Ensuring no session exists..."; + if (sessionExists()) { + auto result = runCli({"config", "session", "clear"}); + ASSERT_EQ(result.exitCode, 0) + << "Failed to clear existing session: " << result.stderr; + } + ASSERT_FALSE(sessionExists()) << "Session should not exist"; + XLOG(INFO) << " No session exists"; + + // Step 2: Try to clear non-existent session + XLOG(INFO) << "[Step 2] Clearing non-existent session..."; + auto result = runCli({"config", "session", "clear"}); + ASSERT_EQ(result.exitCode, 0) + << "Clear should succeed even with no session: " << result.stderr; + XLOG(INFO) << " Command succeeded"; + + // Step 3: Verify output message + XLOG(INFO) << "[Step 3] Verifying output message..."; + EXPECT_TRUE( + result.stdout.find("No config session exists") != std::string::npos) + << "Expected 'No config session exists' message, got: " << result.stdout; + XLOG(INFO) << " Output: " << result.stdout; + + XLOG(INFO) << "TEST PASSED"; +} diff --git a/fboss/oss/cli_tests/cli_test_lib.py b/fboss/oss/cli_tests/cli_test_lib.py index be38ebcf1ec2b..51e76537ac111 100644 --- a/fboss/oss/cli_tests/cli_test_lib.py +++ b/fboss/oss/cli_tests/cli_test_lib.py @@ -239,3 +239,119 @@ def is_valid_eth_interface(intf: Interface) -> bool: def commit_config() -> None: """Commit the current configuration session.""" run_cli(["config", "session", "commit"]) + + +# Paths for config files +SYSTEM_CONFIG_PATH = "/etc/coop/agent.conf" +SESSION_CONFIG_PATH = os.path.expanduser("~/.fboss2/agent.conf") + + +def cleanup_config( + modify_config: Callable[[dict[str, Any]], None], + description: str = "test configs", +) -> None: + """ + Common cleanup helper that modifies the config and commits the changes. + + This function: + 1. Copies the system config to the session config + 2. Loads the config JSON + 3. Calls the modify_config callback to modify the sw config + 4. Writes the modified config back + 5. Updates metadata for AGENT_RESTART + 6. Commits the cleanup + + Args: + modify_config: A callable that takes the sw config dict and modifies it + in place to remove test-specific configurations. + description: A description of what is being cleaned up (for logging). + """ + import shutil + + session_dir = os.path.dirname(SESSION_CONFIG_PATH) + metadata_path = os.path.join(session_dir, "cli_metadata.json") + + print(" Copying system config to session config...") + os.makedirs(session_dir, exist_ok=True) + shutil.copy(SYSTEM_CONFIG_PATH, SESSION_CONFIG_PATH) + + print(f" Removing {description}...") + with open(SESSION_CONFIG_PATH, "r") as f: + config = json.load(f) + + sw_config = config.get("sw", {}) + modify_config(sw_config) + + with open(SESSION_CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2) + + # Update metadata to require AGENT_RESTART + print(" Updating metadata for AGENT_RESTART...") + metadata = { + "action": {"WEDGE_AGENT": "AGENT_RESTART"}, + "commands": [], + "base": "", + } + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + print(" Committing cleanup...") + commit_config() + + +def running_config() -> dict[str, Any]: + """ + Get the running configuration from the FBOSS agent. + + Returns the nested JSON payload, skipping the initial 'localhost' level. + This allows direct access to the configuration without needing to iterate + over host keys. + + Returns: + The configuration dict containing 'sw', 'platform', etc. + """ + data = run_cli(["show", "running-config"]) + + # The JSON has a host key (e.g., "localhost") containing a JSON string + for host_data_str in data.values(): + # The value is a JSON string that needs to be parsed + if isinstance(host_data_str, str): + return json.loads(host_data_str) + return host_data_str + + return {} + + +def wait_for_agent_ready(max_wait_seconds: int = 60) -> bool: + """ + Wait for the wedge_agent to be ready after a restart. + + The agent restart typically takes 40-50 seconds. We wait for an initial + period and then poll until the agent responds with valid data. + + Args: + max_wait_seconds: Maximum time to wait for the agent to be ready. + + Returns: + True if the agent is ready, False if timeout. + """ + # Initial delay - agent restart takes significant time + # This avoids noisy polling during the restart + initial_wait = 30 + print(f" Sleeping {initial_wait}s for agent restart...") + time.sleep(initial_wait) + + # Now poll until agent is ready or timeout + start_time = time.time() + remaining = max_wait_seconds - initial_wait + while time.time() - start_time < remaining: + try: + # Try to get the running config - if it works, agent is ready + data = run_cli(["show", "running-config"], check=False) + # Make sure we got valid data (not empty due to connection issues) + if data and any(data.values()): + return True + except (RuntimeError, json.JSONDecodeError): + pass + time.sleep(2) + return False diff --git a/fboss/oss/cli_tests/test_config_concurrent_sessions.py b/fboss/oss/cli_tests/test_config_concurrent_sessions.py new file mode 100644 index 0000000000000..0a04703433141 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_concurrent_sessions.py @@ -0,0 +1,155 @@ +#!/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 concurrent session conflict detection and rebase. + +This test simulates two users making changes to the configuration at the same +time by using different HOME directories for each "user". It verifies: + +1. User1 can start a session and commit changes +2. User2's commit fails if User1 committed while User2's session was open +3. User2 can rebase their changes onto User1's commit +4. After rebase, User2 can commit successfully + +Requirements: +- FBOSS agent must be running with a valid configuration +- The test must be run as root (or with appropriate permissions) +""" + +import os +import subprocess +import sys +import tempfile + +from cli_test_lib import ( + find_first_eth_interface, + get_fboss_cli, + get_interface_info, +) + + +def run_cli_as_user( + home_dir: str, args: list[str], check: bool = True +) -> subprocess.CompletedProcess: + """Run CLI with a specific HOME directory to simulate a different user.""" + cli = get_fboss_cli() + cmd = [cli] + args + env = os.environ.copy() + env["HOME"] = home_dir + print(f"[User HOME={home_dir}] Running: {' '.join(args)}") + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + if check and result.returncode != 0: + print(f" Failed with code {result.returncode}") + print(f" stdout: {result.stdout}") + print(f" stderr: {result.stderr}") + raise RuntimeError(f"Command failed: {' '.join(cmd)}") + return result + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: Concurrent Session Conflict Detection") + print("=" * 60) + + # Step 1: Find an interface to test with + print("\n[Step 1] Finding interfaces to test...") + interface = find_first_eth_interface() + print(f" Using interface: {interface.name} (VLAN: {interface.vlan})") + + original_description = interface.description + print(f" Original description: '{original_description}'") + + # Create temporary home directories for two simulated users + with tempfile.TemporaryDirectory( + prefix="user1_" + ) as user1_home, tempfile.TemporaryDirectory(prefix="user2_") as user2_home: + + try: + # Step 2: User1 starts a session + print("\n[Step 2] User1 starts a session...") + run_cli_as_user( + user1_home, + ["config", "interface", interface.name, "description", "User1_change"], + ) + print(" User1 session started with description change") + + # Step 3: User2 also starts a session (same base commit) + print("\n[Step 3] User2 starts a session...") + run_cli_as_user( + user2_home, + ["config", "interface", interface.name, "description", "User2_change"], + ) + print(" User2 session started with description change") + + # Step 4: User1 commits first + print("\n[Step 4] User1 commits first...") + run_cli_as_user(user1_home, ["config", "session", "commit"]) + print(" User1 commit succeeded") + + # Verify User1's change is applied + info = get_interface_info(interface.name) + if info.description != "User1_change": + print(f" ERROR: Expected 'User1_change', got '{info.description}'") + return 1 + print(f" Verified: Description is now '{info.description}'") + + # Step 5: User2 tries to commit - should fail + print("\n[Step 5] User2 tries to commit (should fail)...") + result = run_cli_as_user( + user2_home, ["config", "session", "commit"], check=False + ) + print(f" Return code: {result.returncode}") + print(f" stderr: {result.stderr[:300] if result.stderr else '(empty)'}") + # Check for conflict message in stderr (exit code may incorrectly be 0) + if "system configuration has changed" not in result.stderr: + print(f" ERROR: Expected conflict message in stderr") + return 1 + print(" User2's commit correctly rejected with conflict message") + + # Step 6: User2 rebases + print("\n[Step 6] User2 rebases (should fail - conflicting changes)...") + result = run_cli_as_user( + user2_home, ["config", "session", "rebase"], check=False + ) + print(f" Return code: {result.returncode}") + print(f" stderr: {result.stderr[:300] if result.stderr else '(empty)'}") + # This should fail because both users changed the same field + # Check both stdout and stderr for conflict message + output = (result.stdout or "") + (result.stderr or "") + if "conflict" in output.lower(): + print(" Rebase correctly detected conflict") + else: + print(" Note: Rebase may have succeeded or failed with other error") + + print("\n" + "=" * 60) + print("TEST PASSED") + print("=" * 60) + return 0 + + finally: + # Cleanup: Restore original description + print(f"\n[Cleanup] Restoring original description...") + real_home = os.environ.get("HOME", "/root") + try: + run_cli_as_user( + real_home, + [ + "config", + "interface", + interface.name, + "description", + original_description or "", + ], + ) + run_cli_as_user(real_home, ["config", "session", "commit"]) + print(f" Restored description to '{original_description}'") + except Exception as e: + print(f" Warning: Cleanup failed: {e}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/cli_tests/test_config_l2_learning_mode.py b/fboss/oss/cli_tests/test_config_l2_learning_mode.py new file mode 100644 index 0000000000000..239576aa215b0 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_l2_learning_mode.py @@ -0,0 +1,135 @@ +#!/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 l2 learning-mode ' command. + +This test: +1. Gets the current L2 learning mode from the running configuration +2. Sets the L2 learning mode to "software" +3. Commits and verifies the change via the running configuration +4. Sets the L2 learning mode to "hardware" +5. Commits and verifies the change +6. Restores the original mode if different + +Requirements: +- FBOSS agent must be running with a valid configuration +- Must be run on the DUT with appropriate permissions to commit config +""" + +import sys +from typing import Optional + +from cli_test_lib import ( + commit_config, + run_cli, + running_config, + wait_for_agent_ready, +) + + +# L2LearningMode enum values from switch_config.thrift +L2_LEARNING_MODE_HARDWARE = 0 +L2_LEARNING_MODE_SOFTWARE = 1 +L2_LEARNING_MODE_DISABLED = 2 + +MODE_INT_TO_STR = { + L2_LEARNING_MODE_HARDWARE: "hardware", + L2_LEARNING_MODE_SOFTWARE: "software", + L2_LEARNING_MODE_DISABLED: "disabled", +} + +MODE_STR_TO_INT = {v: k for k, v in MODE_INT_TO_STR.items()} + + +def get_l2_learning_mode() -> Optional[int]: + """ + Get the current L2 learning mode from the running configuration. + + Returns: + The L2 learning mode as an integer (0=HARDWARE, 1=SOFTWARE, 2=DISABLED), + or None if not found. + """ + config = running_config() + sw_config = config.get("sw", {}) + switch_settings = sw_config.get("switchSettings", {}) + return switch_settings.get("l2LearningMode", L2_LEARNING_MODE_HARDWARE) + + +def set_l2_learning_mode(mode: str) -> None: + """Set the L2 learning mode and commit the change. + + Note: L2 learning mode changes require an agent restart, so we need to + wait for the agent to be ready after the commit. + """ + run_cli(["config", "l2", "learning-mode", mode]) + commit_config() + # Wait for agent to be ready after restart + print(" Waiting for agent to be ready after restart...") + if not wait_for_agent_ready(max_wait_seconds=60): + raise RuntimeError("Agent did not become ready after restart") + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: config l2 learning-mode ") + print("=" * 60) + + # Step 1: Get current L2 learning mode + print("\n[Step 1] Getting current L2 learning mode...") + original_mode = get_l2_learning_mode() + if original_mode is None: + print( + " WARNING: Could not determine current L2 learning mode, assuming hardware" + ) + original_mode = L2_LEARNING_MODE_HARDWARE + print( + f" Current mode: {MODE_INT_TO_STR.get(original_mode, f'unknown({original_mode})')}" + ) + + # Step 2: Set L2 learning mode to "software" + print("\n[Step 2] Setting L2 learning mode to 'software'...") + set_l2_learning_mode("software") + print(" L2 learning mode set to 'software'") + + # Step 3: Verify the change + print("\n[Step 3] Verifying L2 learning mode is 'software'...") + current_mode = get_l2_learning_mode() + if current_mode != L2_LEARNING_MODE_SOFTWARE: + print(f" ERROR: Expected software mode (1) but got: {current_mode}") + return 1 + print(" Verified: L2 learning mode is 'software'") + + # Step 4: Set L2 learning mode to "hardware" + print("\n[Step 4] Setting L2 learning mode to 'hardware'...") + set_l2_learning_mode("hardware") + print(" L2 learning mode set to 'hardware'") + + # Step 5: Verify the change + print("\n[Step 5] Verifying L2 learning mode is 'hardware'...") + current_mode = get_l2_learning_mode() + if current_mode != L2_LEARNING_MODE_HARDWARE: + print(f" ERROR: Expected hardware mode (0) but got: {current_mode}") + return 1 + print(" Verified: L2 learning mode is 'hardware'") + + # Restore original mode if it was different + if original_mode != L2_LEARNING_MODE_HARDWARE: + original_mode_str = MODE_INT_TO_STR.get(original_mode, "hardware") + print( + f"\n[Cleanup] Restoring original L2 learning mode to '{original_mode_str}'..." + ) + set_l2_learning_mode(original_mode_str) + print(" Original mode restored") + + 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_pfc.py b/fboss/oss/cli_tests/test_config_pfc.py new file mode 100644 index 0000000000000..9c4b284447d8d --- /dev/null +++ b/fboss/oss/cli_tests/test_config_pfc.py @@ -0,0 +1,354 @@ +#!/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 tests for PFC (Priority Flow Control) CLI commands. + +This test covers: +1. Priority group policy configuration (config qos priority-group-policy) +2. Per-port PFC configuration (config interface pfc-config) + +This test: +1. Cleans up any existing test config (portPgConfigs, bufferPoolConfigs, port pfc) +2. Creates a buffer pool (required for priority group config) +3. Creates a new priority group policy with multiple group IDs +4. Configures PFC on a test port with tx, rx, watchdog settings +5. Commits the configuration and verifies it was applied +6. Cleans up the test config +""" + +import json +import sys +from typing import Any + +from cli_test_lib import ( + SYSTEM_CONFIG_PATH, + cleanup_config, + commit_config, + run_cli, +) + +# Test names +TEST_BUFFER_POOL_NAME = "cli_e2e_test_buffer_pool" +TEST_POLICY_NAME = "cli_e2e_test_pg_policy" +TEST_PORT_NAME = "eth1/1/1" + +# Buffer pool configuration +TEST_BUFFER_POOL_CONFIG = { + "sharedBytes": 78773528, + "headroomBytes": 4405376, +} + +# scalingFactor enum values: ONE_HALF=8, TWO=9 +SCALING_FACTOR_MAP = {"ONE_HALF": 8, "TWO": 9} + +# Expected portPgConfigs after test (what we expect in the JSON file) +EXPECTED_PORT_PG_CONFIGS = { + TEST_POLICY_NAME: [ + { + "id": 2, + "name": "pg2", + "sramScalingFactor": SCALING_FACTOR_MAP["ONE_HALF"], + "minLimitBytes": 14478, + "headroomLimitBytes": 726440, + "resumeOffsetBytes": 9652, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + { + "id": 6, + "name": "pg6", + "sramScalingFactor": SCALING_FACTOR_MAP["ONE_HALF"], + "minLimitBytes": 4826, + "headroomLimitBytes": 726440, + "resumeOffsetBytes": 9652, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + { + "id": 0, + "name": "pg0", + "sramScalingFactor": SCALING_FACTOR_MAP["TWO"], + "minLimitBytes": 4826, + "headroomLimitBytes": 0, + "resumeOffsetBytes": 9652, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + { + "id": 7, + "name": "pg7", + "sramScalingFactor": SCALING_FACTOR_MAP["TWO"], + "minLimitBytes": 4826, + "headroomLimitBytes": 0, + "resumeOffsetBytes": 9652, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + ] +} + +# CLI input format (uses string scaling factor names) +CLI_PG_CONFIGS = [ + { + "id": 2, + "scalingFactor": "ONE_HALF", + "minLimitBytes": 14478, + "headroomLimitBytes": 726440, + "resumeOffsetBytes": 9652, + }, + { + "id": 6, + "scalingFactor": "ONE_HALF", + "minLimitBytes": 4826, + "headroomLimitBytes": 726440, + "resumeOffsetBytes": 9652, + }, + { + "id": 0, + "scalingFactor": "TWO", + "minLimitBytes": 4826, + "headroomLimitBytes": 0, + "resumeOffsetBytes": 9652, + }, + { + "id": 7, + "scalingFactor": "TWO", + "minLimitBytes": 4826, + "headroomLimitBytes": 0, + "resumeOffsetBytes": 9652, + }, +] + +# Per-port PFC configuration (similar to l3_scaleup.conf) +# recoveryAction enum: NO_DROP=0, DROP=1 +TEST_PORT_PFC_CONFIG = { + "tx": True, + "rx": True, + "portPgConfigName": TEST_POLICY_NAME, + "watchdog": { + "detectionTimeMsecs": 150, + "recoveryTimeMsecs": 1000, + "recoveryAction": 0, # NO_DROP + }, +} + + +def configure_buffer_pool(pool_name: str, config: dict) -> None: + """Configure a buffer pool with shared and headroom bytes.""" + base_cmd = ["config", "qos", "buffer-pool", pool_name] + run_cli(base_cmd + ["shared-bytes", str(config["sharedBytes"])]) + run_cli(base_cmd + ["headroom-bytes", str(config["headroomBytes"])]) + + +def configure_priority_group( + policy_name: str, group_id: int, config: dict, buffer_pool_name: str +) -> None: + """Configure a single priority group with all its attributes (one at a time).""" + base_cmd = [ + "config", + "qos", + "priority-group-policy", + policy_name, + "group-id", + str(group_id), + ] + + # Set each attribute + run_cli(base_cmd + ["min-limit-bytes", str(config["minLimitBytes"])]) + run_cli(base_cmd + ["headroom-limit-bytes", str(config["headroomLimitBytes"])]) + run_cli(base_cmd + ["resume-offset-bytes", str(config["resumeOffsetBytes"])]) + run_cli(base_cmd + ["scaling-factor", config["scalingFactor"]]) + run_cli(base_cmd + ["buffer-pool-name", buffer_pool_name]) + + +def configure_priority_group_multi_attr( + policy_name: str, group_id: int, config: dict, buffer_pool_name: str +) -> None: + """Configure a single priority group with all attributes in one command.""" + cmd = [ + "config", + "qos", + "priority-group-policy", + policy_name, + "group-id", + str(group_id), + "min-limit-bytes", + str(config["minLimitBytes"]), + "headroom-limit-bytes", + str(config["headroomLimitBytes"]), + "resume-offset-bytes", + str(config["resumeOffsetBytes"]), + "scaling-factor", + config["scalingFactor"], + "buffer-pool-name", + buffer_pool_name, + ] + run_cli(cmd) + + +def configure_port_pfc(port_name: str, config: dict) -> None: + """Configure PFC settings on a port. + + Demonstrates both single-attribute and multi-attribute command variants. + """ + base_cmd = ["config", "interface", port_name, "pfc-config"] + watchdog = config["watchdog"] + recovery_action = "no-drop" if watchdog["recoveryAction"] == 0 else "drop" + + # First, use single-attribute commands for tx, rx, and priority-group-policy + print(" Using single-attribute commands for tx, rx, priority-group-policy...") + run_cli(base_cmd + ["tx", "enabled" if config["tx"] else "disabled"]) + run_cli(base_cmd + ["rx", "enabled" if config["rx"] else "disabled"]) + run_cli(base_cmd + ["priority-group-policy", config["portPgConfigName"]]) + + # Then, use a multi-attribute command for all watchdog settings at once + print(" Using multi-attribute command for all watchdog settings...") + run_cli( + base_cmd + + [ + "watchdog-detection-time", + str(watchdog["detectionTimeMsecs"]), + "watchdog-recovery-time", + str(watchdog["recoveryTimeMsecs"]), + "watchdog-recovery-action", + recovery_action, + ] + ) + + +def cleanup_test_config() -> None: + """Remove PFC-related test config: portPgConfigs, bufferPoolConfigs, and port pfc.""" + + def modify_config(sw_config: dict[str, Any]) -> None: + # Remove global PFC configs + sw_config.pop("portPgConfigs", None) + sw_config.pop("bufferPoolConfigs", None) + + # Remove per-port PFC config from test port + ports = sw_config.get("ports", []) + for port in ports: + if port.get("name") == TEST_PORT_NAME: + port.pop("pfc", None) + break + + cleanup_config(modify_config, "PFC-related configs") + + +def main() -> int: + print("=" * 70) + print("CLI E2E Test: PFC Configuration") + print("=" * 70) + + # Step 0: Cleanup any existing test config + print("\n[Step 0] Cleaning up any existing test config...") + cleanup_test_config() + print(" Cleanup complete") + + # Step 1: Configure buffer pool (required for priority group config) + print(f"\n[Step 1] Configuring buffer pool '{TEST_BUFFER_POOL_NAME}'...") + configure_buffer_pool(TEST_BUFFER_POOL_NAME, TEST_BUFFER_POOL_CONFIG) + print(" Buffer pool configured") + + # Step 2: Configure priority groups (using single-attribute commands) + print(f"\n[Step 2] Configuring priority group policy '{TEST_POLICY_NAME}'...") + print(" Using single-attribute commands for group-ids 2 and 6...") + for pg_config in CLI_PG_CONFIGS[:2]: # First two: group-ids 2 and 6 + group_id = pg_config["id"] + print(f" Configuring group-id {group_id} (one attribute at a time)...") + configure_priority_group( + TEST_POLICY_NAME, group_id, pg_config, TEST_BUFFER_POOL_NAME + ) + print(" Using multi-attribute commands for group-ids 0 and 7...") + for pg_config in CLI_PG_CONFIGS[2:]: # Last two: group-ids 0 and 7 + group_id = pg_config["id"] + print(f" Configuring group-id {group_id} (all attributes at once)...") + configure_priority_group_multi_attr( + TEST_POLICY_NAME, group_id, pg_config, TEST_BUFFER_POOL_NAME + ) + print(" All priority groups configured") + + # Step 3: Configure per-port PFC + print(f"\n[Step 3] Configuring PFC on port '{TEST_PORT_NAME}'...") + configure_port_pfc(TEST_PORT_NAME, TEST_PORT_PFC_CONFIG) + print(" Port PFC configured") + + # Step 4: Commit the configuration + print("\n[Step 4] Committing configuration...") + commit_config() + print(" Configuration committed successfully") + + # Step 5: Verify configuration by reading /etc/coop/agent.conf + print("\n[Step 5] Verifying configuration...") + with open(SYSTEM_CONFIG_PATH, "r") as f: + config = json.load(f) + + sw_config = config.get("sw", {}) + + # Verify buffer pool + actual_buffer_pool = sw_config.get("bufferPoolConfigs", {}).get( + TEST_BUFFER_POOL_NAME + ) + if actual_buffer_pool != TEST_BUFFER_POOL_CONFIG: + print(" ERROR: Buffer pool mismatch") + print(f" Expected: {TEST_BUFFER_POOL_CONFIG}") + print(f" Actual: {actual_buffer_pool}") + return 1 + print(f" Buffer pool '{TEST_BUFFER_POOL_NAME}' verified") + + # Verify priority group policy using deep equal + actual_pg_configs = sw_config.get("portPgConfigs", {}) + if TEST_POLICY_NAME not in actual_pg_configs: + print(f" ERROR: Priority group policy '{TEST_POLICY_NAME}' not found") + return 1 + + # Sort both lists by id for comparison + expected_list = sorted( + EXPECTED_PORT_PG_CONFIGS[TEST_POLICY_NAME], key=lambda x: x["id"] + ) + actual_list = sorted(actual_pg_configs[TEST_POLICY_NAME], key=lambda x: x["id"]) + + if actual_list != expected_list: + print(" ERROR: Priority group configs mismatch") + print(f" Expected: {json.dumps(expected_list, indent=2)}") + print(f" Actual: {json.dumps(actual_list, indent=2)}") + return 1 + print(f" Priority group policy '{TEST_POLICY_NAME}' verified") + + # Verify per-port PFC config + ports = sw_config.get("ports", []) + test_port = None + for port in ports: + if port.get("name") == TEST_PORT_NAME: + test_port = port + break + + if test_port is None: + print(f" ERROR: Port '{TEST_PORT_NAME}' not found in config") + return 1 + + actual_pfc = test_port.get("pfc") + if actual_pfc is None: + print(f" ERROR: PFC config not found on port '{TEST_PORT_NAME}'") + return 1 + + if actual_pfc != TEST_PORT_PFC_CONFIG: + print(" ERROR: Port PFC config mismatch") + print(f" Expected: {json.dumps(TEST_PORT_PFC_CONFIG, indent=2)}") + print(f" Actual: {json.dumps(actual_pfc, indent=2)}") + return 1 + print(f" Port '{TEST_PORT_NAME}' PFC config verified") + + print("\n" + "=" * 70) + print("TEST PASSED") + print("=" * 70) + + # Step 6: Cleanup test config + print("\n[Step 6] Cleaning up test config...") + cleanup_test_config() + print(" Cleanup complete") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/cli_tests/test_config_portqueuecfg.py b/fboss/oss/cli_tests/test_config_portqueuecfg.py new file mode 100644 index 0000000000000..94feeb42ff6a8 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_portqueuecfg.py @@ -0,0 +1,343 @@ +#!/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 tests for Port Queue Config CLI commands. + +This test covers: +1. Queuing policy configuration (config qos queuing-policy) + +This test: +1. Cleans up any existing test config (portQueueConfigs) +2. Creates a new queuing policy with multiple queue IDs +3. Configures various queue attributes (scheduling, weight, sharedBytes, etc.) +4. Commits the configuration and verifies it was applied +5. Cleans up the test config + +Based on sample config from l3_scaleup.conf lines 5825-5942. +""" + +import json +import sys +from typing import Any + +from cli_test_lib import ( + SYSTEM_CONFIG_PATH, + cleanup_config, + commit_config, + find_first_eth_interface, + run_cli, +) + +# Test names +TEST_POLICY_NAME = "cli_e2e_test_queue_policy" +TEST_BUFFER_POOL_NAME = "cli_e2e_test_buffer_pool" + +# QueueScheduling enum values: WEIGHTED_ROUND_ROBIN=0, STRICT_PRIORITY=1, DEFICIT_ROUND_ROBIN=2 +SCHEDULING_MAP = {"WRR": 0, "SP": 1, "DRR": 2} + +# MMUScalingFactor enum values +SCALING_FACTOR_MAP = {"ONE_HALF": 8, "TWO": 9, "FOUR": 10} + +# StreamType enum values: UNICAST=0, MULTICAST=1, ALL=2, FABRIC_TX=3 +STREAM_TYPE_MAP = {"UNICAST": 0, "MULTICAST": 1, "ALL": 2, "FABRIC_TX": 3} + +# QueueCongestionBehavior enum values: EARLY_DROP=0, ECN=1 +CONGESTION_BEHAVIOR_MAP = {"EARLY_DROP": 0, "ECN": 1} + +# Buffer pool configuration (needed for buffer-pool-name reference) +TEST_BUFFER_POOL_CONFIG = { + "sharedBytes": 78773528, + "headroomBytes": 4405376, +} + +# CLI input format for queue configs (uses string values) +# Based on l3_scaleup.conf structure but using attributes we support +CLI_QUEUE_CONFIGS = [ + { + "id": 2, + "scheduling": "SP", + "sharedBytes": 83618832, + "weight": 10, + }, + { + "id": 6, + "scheduling": "SP", + "sharedBytes": 83618832, + "scalingFactor": "TWO", + }, + { + "id": 7, + "scheduling": "WRR", + "weight": 20, + "reservedBytes": 5000, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + { + "id": 3, + "scheduling": "WRR", + "weight": 15, + "streamType": "MULTICAST", + }, + { + "id": 4, + "scheduling": "SP", + "sharedBytes": 83618832, + "aqm": { + "behavior": "ECN", + "detection": { + "linear": { + "minimumLength": 120000, + "maximumLength": 120000, + "probability": 100, + } + }, + }, + }, +] + +# Expected portQueueConfigs after test (what we expect in the JSON file) +# Note: streamType defaults to 0 (UNICAST) +EXPECTED_PORT_QUEUE_CONFIGS = { + TEST_POLICY_NAME: [ + { + "id": 2, + "streamType": 0, + "scheduling": SCHEDULING_MAP["SP"], + "sharedBytes": 83618832, + "weight": 10, + }, + { + "id": 6, + "streamType": 0, + "scheduling": SCHEDULING_MAP["SP"], + "sharedBytes": 83618832, + "scalingFactor": SCALING_FACTOR_MAP["TWO"], + }, + { + "id": 7, + "streamType": 0, + "scheduling": SCHEDULING_MAP["WRR"], + "weight": 20, + "reservedBytes": 5000, + "bufferPoolName": TEST_BUFFER_POOL_NAME, + }, + { + "id": 3, + "streamType": STREAM_TYPE_MAP["MULTICAST"], + "scheduling": SCHEDULING_MAP["WRR"], + "weight": 15, + }, + { + "id": 4, + "streamType": 0, + "scheduling": SCHEDULING_MAP["SP"], + "sharedBytes": 83618832, + "aqms": [ + { + "behavior": CONGESTION_BEHAVIOR_MAP["ECN"], + "detection": { + "linear": { + "minimumLength": 120000, + "maximumLength": 120000, + "probability": 100, + } + }, + } + ], + }, + ] +} + + +def configure_buffer_pool(pool_name: str, config: dict) -> None: + """Configure a buffer pool with shared and headroom bytes.""" + base_cmd = ["config", "qos", "buffer-pool", pool_name] + run_cli(base_cmd + ["shared-bytes", str(config["sharedBytes"])]) + run_cli(base_cmd + ["headroom-bytes", str(config["headroomBytes"])]) + + +def configure_queue(policy_name: str, queue_id: int, config: dict) -> None: + """Configure a single queue with its attributes. + + Uses the new key-value pair syntax: + config qos queuing-policy queue-id [ ...] + """ + # Build command with queue-id followed by key-value pairs + cmd = [ + "config", + "qos", + "queuing-policy", + policy_name, + "queue-id", + str(queue_id), + ] + + # Add each attribute as key-value pairs + if "scheduling" in config: + cmd.extend(["scheduling", config["scheduling"]]) + if "weight" in config: + cmd.extend(["weight", str(config["weight"])]) + if "sharedBytes" in config: + cmd.extend(["shared-bytes", str(config["sharedBytes"])]) + if "reservedBytes" in config: + cmd.extend(["reserved-bytes", str(config["reservedBytes"])]) + if "scalingFactor" in config: + cmd.extend(["scaling-factor", config["scalingFactor"]]) + if "streamType" in config: + cmd.extend(["stream-type", config["streamType"]]) + if "bufferPoolName" in config: + cmd.extend(["buffer-pool-name", config["bufferPoolName"]]) + + # Handle active-queue-management (AQM) configuration + # AQM must come last as it consumes all remaining arguments + if "aqm" in config: + aqm = config["aqm"] + cmd.append("active-queue-management") + if "behavior" in aqm: + cmd.extend(["congestion-behavior", aqm["behavior"]]) + if "detection" in aqm: + detection = aqm["detection"] + if "linear" in detection: + linear = detection["linear"] + cmd.extend(["detection", "linear"]) + if "minimumLength" in linear: + cmd.extend(["minimum-length", str(linear["minimumLength"])]) + if "maximumLength" in linear: + cmd.extend(["maximum-length", str(linear["maximumLength"])]) + if "probability" in linear: + cmd.extend(["probability", str(linear["probability"])]) + + run_cli(cmd) + + +def cleanup_test_config(interface_name: str) -> None: + """Remove port queue config test data. + + Args: + interface_name: Interface name to remove portQueueConfigName from. + """ + + def modify_config(sw_config: dict[str, Any]) -> None: + # Remove test configs + port_queue_configs = sw_config.get("portQueueConfigs", {}) + port_queue_configs.pop(TEST_POLICY_NAME, None) + buffer_pool_configs = sw_config.get("bufferPoolConfigs", {}) + buffer_pool_configs.pop(TEST_BUFFER_POOL_NAME, None) + # Remove portQueueConfigName from test interface + for port in sw_config.get("ports", []): + if port.get("name") == interface_name: + port.pop("portQueueConfigName", None) + break + + cleanup_config(modify_config, "port queue configs") + + +def main() -> int: + print("=" * 70) + print("CLI E2E Test: Port Queue Configuration") + print("=" * 70) + + # Find a test interface + test_intf = find_first_eth_interface() + print(f"Using test interface: {test_intf.name}") + + # Step 0: Cleanup any existing test config + print("\n[Step 0] Cleaning up any existing test config...") + cleanup_test_config(test_intf.name) + print(" Cleanup complete") + + # Step 1: Configure buffer pool (needed for buffer-pool-name reference) + print(f"\n[Step 1] Configuring buffer pool '{TEST_BUFFER_POOL_NAME}'...") + configure_buffer_pool(TEST_BUFFER_POOL_NAME, TEST_BUFFER_POOL_CONFIG) + print(" Buffer pool configured") + + # Step 2: Configure queues + print(f"\n[Step 2] Configuring queuing policy '{TEST_POLICY_NAME}'...") + for queue_config in CLI_QUEUE_CONFIGS: + queue_id = queue_config["id"] + print(f" Configuring queue-id {queue_id}...") + configure_queue(TEST_POLICY_NAME, queue_id, queue_config) + print(" All queues configured") + # Assign queuing policy to interface + print(f" Assigning queuing policy to interface '{test_intf.name}'...") + run_cli(["config", "interface", test_intf.name, "queuing-policy", TEST_POLICY_NAME]) + print(" Queuing policy assigned") + + # Step 3: Commit the configuration + print("\n[Step 3] Committing configuration...") + commit_config() + print(" Configuration committed successfully") + + # Step 4: Verify configuration by reading /etc/coop/agent.conf + print("\n[Step 4] Verifying configuration...") + with open(SYSTEM_CONFIG_PATH, "r") as f: + config = json.load(f) + + sw_config = config.get("sw", {}) + + # Verify buffer pool + actual_buffer_pool = sw_config.get("bufferPoolConfigs", {}).get( + TEST_BUFFER_POOL_NAME + ) + if actual_buffer_pool != TEST_BUFFER_POOL_CONFIG: + print(" ERROR: Buffer pool mismatch") + print(f" Expected: {TEST_BUFFER_POOL_CONFIG}") + print(f" Actual: {actual_buffer_pool}") + return 1 + print(f" Buffer pool '{TEST_BUFFER_POOL_NAME}' verified") + + # Verify port queue configs + actual_queue_configs = sw_config.get("portQueueConfigs", {}) + if TEST_POLICY_NAME not in actual_queue_configs: + print(f" ERROR: Queuing policy '{TEST_POLICY_NAME}' not found") + return 1 + + # Sort both lists by id for comparison + expected_list = sorted( + EXPECTED_PORT_QUEUE_CONFIGS[TEST_POLICY_NAME], key=lambda x: x["id"] + ) + actual_list = sorted(actual_queue_configs[TEST_POLICY_NAME], key=lambda x: x["id"]) + + if actual_list != expected_list: + print(" ERROR: Port queue configs mismatch") + print(f" Expected: {json.dumps(expected_list, indent=2)}") + print(f" Actual: {json.dumps(actual_list, indent=2)}") + return 1 + print(f" Queuing policy '{TEST_POLICY_NAME}' verified") + + # Verify interface has queuing policy assigned + ports = sw_config.get("ports", []) + test_port = None + for port in ports: + if port.get("name") == test_intf.name: + test_port = port + break + if test_port is None: + print(f" ERROR: Interface '{test_intf.name}' not found in config") + return 1 + actual_policy = test_port.get("portQueueConfigName") + if actual_policy != TEST_POLICY_NAME: + print(f" ERROR: Interface '{test_intf.name}' portQueueConfigName mismatch") + print(f" Expected: {TEST_POLICY_NAME}") + print(f" Actual: {actual_policy}") + return 1 + print(f" Interface '{test_intf.name}' queuing policy verified") + + print("\n" + "=" * 70) + print("TEST PASSED") + print("=" * 70) + + # Step 5: Cleanup test config + print("\n[Step 5] Cleaning up test config...") + cleanup_test_config(test_intf.name) + print(" Cleanup complete") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/cli_tests/test_config_qos.py b/fboss/oss/cli_tests/test_config_qos.py new file mode 100644 index 0000000000000..1116780fd7bf0 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_qos.py @@ -0,0 +1,194 @@ +#!/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 tests for QoS Policy CLI commands. + +This test covers: +1. QoS policy creation with map entries (config qos policy map ...) + +This test: +1. Cleans up any existing test QoS policy +2. Creates a new QoS policy with various map entries: + - trafficClassToQueueId (tc-to-queue) + - pfcPriorityToQueueId (pfc-pri-to-queue) + - trafficClassToPgId (tc-to-pg) + - pfcPriorityToPgId (pfc-pri-to-pg) +3. Commits the configuration and verifies it was applied +4. Cleans up the test config +""" + +import json +import sys +from typing import Any, Dict, Optional + +from cli_test_lib import ( + SYSTEM_CONFIG_PATH, + cleanup_config, + commit_config, + run_cli, +) + +# Test policy name +TEST_POLICY_NAME = "cli_e2e_test_qos_policy" + +# Expected QoS map configuration (based on l3_scaleup.conf sample config) +# All maps use identity mapping: 0->0, 1->1, ..., 7->7 +EXPECTED_TC_TO_QUEUE = {str(i): i for i in range(8)} +EXPECTED_PFC_PRI_TO_QUEUE = {str(i): i for i in range(8)} +EXPECTED_TC_TO_PG = {str(i): i for i in range(8)} +EXPECTED_PFC_PRI_TO_PG = {str(i): i for i in range(8)} + + +def configure_qos_policy_maps(policy_name: str) -> None: + """Configure QoS policy with all map entries.""" + base_cmd = ["config", "qos", "policy", policy_name, "map"] + + # Configure trafficClassToQueueId (tc-to-queue) + print(" Configuring trafficClassToQueueId (tc-to-queue)...") + for tc, queue in EXPECTED_TC_TO_QUEUE.items(): + run_cli(base_cmd + ["tc-to-queue", tc, str(queue)]) + + # Configure pfcPriorityToQueueId (pfc-pri-to-queue) + print(" Configuring pfcPriorityToQueueId (pfc-pri-to-queue)...") + for pfc_pri, queue in EXPECTED_PFC_PRI_TO_QUEUE.items(): + run_cli(base_cmd + ["pfc-pri-to-queue", pfc_pri, str(queue)]) + + # Configure trafficClassToPgId (tc-to-pg) + print(" Configuring trafficClassToPgId (tc-to-pg)...") + for tc, pg in EXPECTED_TC_TO_PG.items(): + run_cli(base_cmd + ["tc-to-pg", tc, str(pg)]) + + # Configure pfcPriorityToPgId (pfc-pri-to-pg) + print(" Configuring pfcPriorityToPgId (pfc-pri-to-pg)...") + for pfc_pri, pg in EXPECTED_PFC_PRI_TO_PG.items(): + run_cli(base_cmd + ["pfc-pri-to-pg", pfc_pri, str(pg)]) + + +def cleanup_test_config() -> None: + """Remove test QoS policy from config.""" + + def modify_config(sw_config: Dict[str, Any]) -> None: + # Remove test QoS policy from qosPolicies list + qos_policies = sw_config.get("qosPolicies", []) + sw_config["qosPolicies"] = [ + p for p in qos_policies if p.get("name") != TEST_POLICY_NAME + ] + + cleanup_config(modify_config, "QoS policy test configs") + + +def verify_map( + actual: Optional[Dict[str, int]], expected: Dict[str, int], map_name: str +) -> bool: + """Verify a QoS map matches expected values.""" + if actual is None: + print(f" ERROR: {map_name} is None") + return False + + if actual != expected: + print(f" ERROR: {map_name} mismatch") + print(f" Expected: {expected}") + print(f" Actual: {actual}") + return False + + print(f" {map_name} verified") + return True + + +def main() -> int: + print("=" * 70) + print("CLI E2E Test: QoS Policy Configuration") + print("=" * 70) + + # Step 0: Cleanup any existing test config + print("\n[Step 0] Cleaning up any existing test config...") + cleanup_test_config() + print(" Cleanup complete") + + # Step 1: Configure QoS policy with map entries + print(f"\n[Step 1] Configuring QoS policy '{TEST_POLICY_NAME}'...") + configure_qos_policy_maps(TEST_POLICY_NAME) + print(" QoS policy configured") + + # Step 2: Commit the configuration + print("\n[Step 2] Committing configuration...") + commit_config() + print(" Configuration committed successfully") + + # Step 3: Verify configuration by reading /etc/coop/agent.conf + print("\n[Step 3] Verifying configuration...") + with open(SYSTEM_CONFIG_PATH, "r") as f: + config = json.load(f) + + sw_config = config.get("sw", {}) + + # Find our test policy in qosPolicies list + qos_policies = sw_config.get("qosPolicies", []) + test_policy = None + for policy in qos_policies: + if policy.get("name") == TEST_POLICY_NAME: + test_policy = policy + break + + if test_policy is None: + print(f" ERROR: QoS policy '{TEST_POLICY_NAME}' not found in config") + return 1 + print(f" QoS policy '{TEST_POLICY_NAME}' found") + + # Verify qosMap exists + qos_map = test_policy.get("qosMap") + if qos_map is None: + print(" ERROR: qosMap not found in policy") + return 1 + + # Verify each map + all_verified = True + + # trafficClassToQueueId + actual_tc_to_queue = qos_map.get("trafficClassToQueueId") + if not verify_map( + actual_tc_to_queue, EXPECTED_TC_TO_QUEUE, "trafficClassToQueueId" + ): + all_verified = False + + # pfcPriorityToQueueId + actual_pfc_to_queue = qos_map.get("pfcPriorityToQueueId") + if not verify_map( + actual_pfc_to_queue, EXPECTED_PFC_PRI_TO_QUEUE, "pfcPriorityToQueueId" + ): + all_verified = False + + # trafficClassToPgId + actual_tc_to_pg = qos_map.get("trafficClassToPgId") + if not verify_map(actual_tc_to_pg, EXPECTED_TC_TO_PG, "trafficClassToPgId"): + all_verified = False + + # pfcPriorityToPgId + actual_pfc_to_pg = qos_map.get("pfcPriorityToPgId") + if not verify_map(actual_pfc_to_pg, EXPECTED_PFC_PRI_TO_PG, "pfcPriorityToPgId"): + all_verified = False + + if not all_verified: + print("\n" + "=" * 70) + print("TEST FAILED") + print("=" * 70) + return 1 + + print("\n" + "=" * 70) + print("TEST PASSED") + print("=" * 70) + + # Step 4: Cleanup test config + print("\n[Step 4] Cleaning up test config...") + cleanup_test_config() + print(" Cleanup complete") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fboss/oss/cli_tests/test_config_vlan_port_tagging_mode.py b/fboss/oss/cli_tests/test_config_vlan_port_tagging_mode.py new file mode 100644 index 0000000000000..6d2bfb10e338d --- /dev/null +++ b/fboss/oss/cli_tests/test_config_vlan_port_tagging_mode.py @@ -0,0 +1,139 @@ +#!/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 vlan port taggingMode' command. + +This test: +1. Picks an interface from the running system with a valid VLAN +2. Gets the current tagging mode for the port on that VLAN +3. Sets the tagging mode to "tagged" +4. Commits and verifies the change via the running configuration +5. Sets the tagging mode back to "untagged" +6. Commits and verifies the change + +Requirements: +- FBOSS agent must be running with a valid configuration +- Must be run on the DUT with appropriate permissions to commit config +""" + +import sys +from typing import Optional + +from cli_test_lib import ( + commit_config, + find_first_eth_interface, + run_cli, +) + + +def get_vlan_port_tagging_mode(vlan_id: int, port_name: str) -> Optional[bool]: + """ + Get the current tagging mode (emitTags) for a port on a VLAN. + + Returns: + True if tagged (emitTags=true), False if untagged (emitTags=false), + None if the port is not a member of the VLAN. + """ + import json as json_module + + data = run_cli(["show", "running-config"]) + + # The JSON has a host key (e.g., "localhost") containing a JSON string + for host_data_str in data.values(): + # The value is a JSON string that needs to be parsed + if isinstance(host_data_str, str): + host_data = json_module.loads(host_data_str) + else: + host_data = host_data_str + + sw_config = host_data.get("sw", {}) + vlan_ports = sw_config.get("vlanPorts", []) + port_list = sw_config.get("ports", []) + + # Build a map of logicalID -> port name + logical_to_name = {} + for port in port_list: + logical_to_name[port.get("logicalID")] = port.get("name") + + for vp in vlan_ports: + if vp.get("vlanID") == vlan_id: + logical_port = vp.get("logicalPort") + if logical_to_name.get(logical_port) == port_name: + return vp.get("emitTags", False) + return None + + +def set_tagging_mode(vlan_id: int, port_name: str, mode: str) -> None: + """Set the tagging mode for a port on a VLAN and commit the change.""" + run_cli(["config", "vlan", str(vlan_id), "port", port_name, "taggingMode", mode]) + commit_config() + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: config vlan port taggingMode") + print("=" * 60) + + # Step 1: Get an interface to test with + print("\n[Step 1] Finding an interface to test...") + interface = find_first_eth_interface() + if interface.vlan is None: + print(" ERROR: Interface has no VLAN assigned") + return 1 + print(f" Using interface: {interface.name} (VLAN: {interface.vlan})") + + vlan_id = interface.vlan + port_name = interface.name + + # Step 2: Get current tagging mode + print(f"\n[Step 2] Getting current tagging mode...") + original_mode = get_vlan_port_tagging_mode(vlan_id, port_name) + if original_mode is None: + print(f" WARNING: Could not determine current tagging mode, assuming untagged") + original_mode = False + print(f" Current mode: {'tagged' if original_mode else 'untagged'}") + + # Step 3: Set tagging mode to "tagged" + print(f"\n[Step 3] Setting tagging mode to 'tagged'...") + set_tagging_mode(vlan_id, port_name, "tagged") + print(f" Tagging mode set to 'tagged'") + + # Step 4: Verify the change + print("\n[Step 4] Verifying tagging mode is 'tagged'...") + current_mode = get_vlan_port_tagging_mode(vlan_id, port_name) + if current_mode is not True: + print(f" ERROR: Expected tagged mode but got: {current_mode}") + return 1 + print(f" Verified: Tagging mode is 'tagged'") + + # Step 5: Set tagging mode to "untagged" + print(f"\n[Step 5] Setting tagging mode to 'untagged'...") + set_tagging_mode(vlan_id, port_name, "untagged") + print(f" Tagging mode set to 'untagged'") + + # Step 6: Verify the change + print("\n[Step 6] Verifying tagging mode is 'untagged'...") + current_mode = get_vlan_port_tagging_mode(vlan_id, port_name) + if current_mode is not False: + print(f" ERROR: Expected untagged mode but got: {current_mode}") + return 1 + print(f" Verified: Tagging mode is 'untagged'") + + # Restore original mode if it was different + if original_mode: + print(f"\n[Cleanup] Restoring original tagging mode to 'tagged'...") + set_tagging_mode(vlan_id, port_name, "tagged") + print(f" Original mode restored") + + 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_vlan_static_mac.py b/fboss/oss/cli_tests/test_config_vlan_static_mac.py new file mode 100755 index 0000000000000..abd04c646bd14 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_vlan_static_mac.py @@ -0,0 +1,142 @@ +#!/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 vlan static-mac add/delete' commands. + +This test: +1. Picks an interface from the running system with a valid VLAN +2. Adds a static MAC entry for that VLAN +3. Verifies the MAC entry was added via 'fboss2-dev show mac details' +4. Deletes the static MAC entry +5. Verifies the MAC entry was deleted + +Requirements: +- FBOSS agent must be running with a valid configuration +- The test must be run as root (or with appropriate permissions) +""" + +import sys +from typing import Optional + +from cli_test_lib import ( + commit_config, + find_first_eth_interface, + run_cli, +) + +# Test MAC address - using a locally administered unicast MAC +# (second hex digit is 2, 6, A, or E for locally administered) +TEST_MAC_ADDRESS = "02:00:00:E2:E2:01" + + +def get_mac_entries() -> list[dict]: + """Get all MAC entries from 'fboss2-dev show mac details'.""" + data = run_cli(["show", "mac", "details"]) + + # The JSON has a host key (e.g., "127.0.0.1") containing the L2 entries + entries: list[dict] = [] + for host_data in data.values(): + for entry in host_data.get("l2Entries", []): + entries.append(entry) + return entries + + +def find_mac_entry(mac_address: str, vlan_id: int) -> Optional[dict]: + """Find a MAC entry by MAC address and VLAN ID. + + Since reloadConfig is synchronous, no retry logic is needed. + """ + normalized_mac = mac_address.upper() + entries = get_mac_entries() + for entry in entries: + entry_mac = entry.get("mac", "").upper() + entry_vlan = entry.get("vlanID") + if entry_mac == normalized_mac and entry_vlan == vlan_id: + return entry + return None + + +def add_static_mac(vlan_id: int, mac_address: str, port_name: str) -> None: + """Add a static MAC entry and commit the change.""" + run_cli( + ["config", "vlan", str(vlan_id), "static-mac", "add", mac_address, port_name] + ) + commit_config() + + +def delete_static_mac(vlan_id: int, mac_address: str) -> None: + """Delete a static MAC entry and commit the change.""" + run_cli(["config", "vlan", str(vlan_id), "static-mac", "delete", mac_address]) + commit_config() + + +def main() -> int: + print("=" * 60) + print("CLI E2E Test: config vlan static-mac add/delete") + print("=" * 60) + + # Step 1: Get an interface to test with + print("\n[Step 1] Finding an interface to test...") + interface = find_first_eth_interface() + if interface.vlan is None: + print(" ERROR: Interface has no VLAN assigned") + return 1 + print(f" Using interface: {interface.name} (VLAN: {interface.vlan})") + + vlan_id = interface.vlan + port_name = interface.name + + # Check if the test MAC already exists (cleanup from a previous failed run) + existing_entry = find_mac_entry(TEST_MAC_ADDRESS, vlan_id) + if existing_entry is not None: + print(f"\n[Cleanup] Removing existing test MAC entry...") + try: + delete_static_mac(vlan_id, TEST_MAC_ADDRESS) + print(f" Removed existing entry for {TEST_MAC_ADDRESS}") + except Exception as e: + print(f" WARNING: Could not remove existing entry: {e}") + + # Step 2: Add a static MAC entry + print(f"\n[Step 2] Adding static MAC entry...") + print(f" VLAN: {vlan_id}, MAC: {TEST_MAC_ADDRESS}, Port: {port_name}") + add_static_mac(vlan_id, TEST_MAC_ADDRESS, port_name) + print(f" Static MAC entry added") + + # Step 3: Verify the MAC entry via 'show mac details' + print("\n[Step 3] Verifying MAC entry via 'show mac details'...") + entry = find_mac_entry(TEST_MAC_ADDRESS, vlan_id) + if entry is None: + print(f" ERROR: MAC entry not found for {TEST_MAC_ADDRESS} on VLAN {vlan_id}") + return 1 + print( + f" Verified: MAC entry found - MAC: {entry.get('mac')}, " + f"VLAN: {entry.get('vlanID')}, Port: {entry.get('ifName')}" + ) + + # Step 4: Delete the static MAC entry + print(f"\n[Step 4] Deleting static MAC entry...") + delete_static_mac(vlan_id, TEST_MAC_ADDRESS) + print(f" Static MAC entry deleted") + + # Step 5: Verify the MAC entry was deleted + print("\n[Step 5] Verifying MAC entry was deleted...") + entry = find_mac_entry(TEST_MAC_ADDRESS, vlan_id) + if entry is not None: + print( + f" ERROR: MAC entry still exists for {TEST_MAC_ADDRESS} on VLAN {vlan_id}" + ) + return 1 + print(f" Verified: MAC entry no longer exists") + + 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 6959cfd12cef5..c15572f36fafb 100755 --- a/fboss/oss/scripts/run_scripts/run_test.py +++ b/fboss/oss/scripts/run_scripts/run_test.py @@ -1474,112 +1474,111 @@ 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 tests are C++ gtest-based tests that run CLI commands and verify output. + They test the CLI tool itself (fboss2-dev) on a running FBOSS instance. + + CLI tests are platform/SAI independent - they test the CLI binary which + communicates with the agent via Thrift, regardless of the underlying + hardware abstraction layer. """ - CLI_TEST_DIR = "./share/cli_tests" + def add_subcommand_arguments(self, sub_parser: ArgumentParser): + """Add CLI test-specific command line arguments""" + pass + + def _get_config_path(self): + # CLI tests don't need a config file - they run against the already-running agent + return "" + + def _get_known_bad_tests_file(self): + return "" + + def _get_unsupported_tests_file(self): + return "" + + def _get_test_binary_name(self): + return "cli_test" + + def _get_sai_replayer_logging_flags( + self, sai_replayer_log_path: Optional[str] + ) -> List[str]: + return [] + + def _get_sai_logging_flags(self): + return [] + + def _get_warmboot_check_file(self): + return "" + + def _get_test_run_args(self, conf_file): + # CLI tests don't need any additional args + return [] + + def _setup_coldboot_test(self, sai_replayer_log_path: Optional[str] = None): + pass + + def _setup_warmboot_test(self, sai_replayer_log_path: Optional[str] = None): + pass + + def _end_run(self): + pass + + def _filter_tests(self, tests: List[str]) -> List[str]: + return 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 + """ + Run CLI end-to-end tests. - # 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)) + CLI tests are simpler than hardware tests - they don't need config file + manipulation, warmboot/coldboot setup, or SAI-specific logging. They just + run against the already-running agent via Thrift. + """ + tests_to_run = self._get_tests_to_run() + tests_to_run = self._filter_tests(tests_to_run) - if not test_scripts: - print(f"No CLI test scripts found in {test_dir}") + if args.list_tests: + # Tests were already printed by _get_tests_to_run 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: + if not tests_to_run: + print("No tests to run") + return + + print(f"Running {len(tests_to_run)} CLI end-to-end tests...") + start_time = datetime.now() + + # Run all tests in a single gtest invocation + test_filter = ":".join(tests_to_run) + # Use /tmp for test results since CLI tests may not have write access + # to the default TESTRESULT_FILE location + result_file = "/tmp/cli_test_results.xml" + cmd = [ + self._get_test_binary_name(), + f"--gtest_filter={test_filter}", + f"--gtest_output=xml:{result_file}", + ] + + print(f"Running command: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + timeout=args.test_run_timeout, + ) + + end_time = datetime.now() + delta_time = end_time - start_time + print(f"Running all tests took {delta_time}") + + if result.returncode != 0: + sys.exit(result.returncode) + + except subprocess.TimeoutExpired: + print(f"[ TIMEOUT ] CLI tests timed out after {args.test_run_timeout}s") + sys.exit(1) + except Exception as e: + print(f"[ ERROR ] Failed to run CLI tests: {e}") sys.exit(1) @@ -1793,6 +1792,7 @@ def run_test(self, args): ) cli_test_runner = CliTestRunner() cli_test_parser.set_defaults(func=cli_test_runner.run_test) + cli_test_runner.add_subcommand_arguments(cli_test_parser) # Parse the args args = ap.parse_known_args()