From 0f56ea6767369dac0029547b251e3ab30901a001 Mon Sep 17 00:00:00 2001 From: Manoharan Sundaramoorthy Date: Mon, 12 Jan 2026 19:55:23 +0530 Subject: [PATCH 01/18] Add buffer-pool configuration support 1. Some of the configuration commands like QoS buffer pool modification requires an agent restart and some are hitless. Inorder to take appropriate action during the commit, track the highest impact action by storing the same in `$HOME/.fboss2/action_level` at the end of each configuration change. The proposed change only prints a warning if the action needs to be taken. Later this can be modified to perform the action automatically 2. Add QoS buffer pool configuration commands ``` - fboss2 config qos buffer-pool shared-bytes - fboss2 config qos buffer-pool headroom-bytes - fboss2 config qos buffer-pool reserved-bytes ``` 1. Updated existing UT for action information 2. Added new tests for QoS buffer bool configurations. ``` [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos buffer-pool testpool headroom-bytes 1024 Successfully set headroom-bytes for buffer-pool 'testpool' to 1024 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config session diff --- current live config +++ session config @@ -121,6 +121,12 @@ "arpAgerInterval": 5, "arpRefreshSeconds": 20, "arpTimeoutSeconds": 60, + "bufferPoolConfigs": { + "testpool": { + "headroomBytes": 1024, + "sharedBytes": 0 + } + }, "clientIdToAdminDistance": { "0": 20, "1": 1, [admin@fboss101 ~]$ ll ~/.fboss2 total 216 -rw-r--r-- 1 admin admin 213283 Jan 13 05:15 agent.conf -rw-r--r-- 1 admin admin 42 Jan 13 05:15 conf_metadata.json [admin@fboss101 ~]$ cat ~/.fboss2/conf_metadata.json { "action": { "WEDGE_AGENT": "AGENT_RESTART" } } ``` --- cmake/CliFboss2.cmake | 11 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 13 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 5 + fboss/cli/fboss2/CmdListConfig.cpp | 16 + fboss/cli/fboss2/CmdSubcommands.cpp | 7 + fboss/cli/fboss2/cli_metadata.thrift | 35 ++ .../fboss2/commands/config/qos/CmdConfigQos.h | 35 ++ .../buffer_pool/CmdConfigQosBufferPool.cpp | 155 +++++++++ .../qos/buffer_pool/CmdConfigQosBufferPool.h | 67 ++++ .../config/session/CmdConfigSessionCommit.cpp | 19 +- fboss/cli/fboss2/session/ConfigSession.cpp | 224 ++++++++++++- fboss/cli/fboss2/session/ConfigSession.h | 66 +++- fboss/cli/fboss2/test/BUCK | 1 + .../test/CmdConfigQosBufferPoolTest.cpp | 311 ++++++++++++++++++ .../cli/fboss2/test/CmdConfigSessionTest.cpp | 181 +++++++++- fboss/cli/fboss2/utils/CmdUtilsCommon.h | 1 + 17 files changed, 1117 insertions(+), 31 deletions(-) create mode 100644 fboss/cli/fboss2/cli_metadata.thrift create mode 100644 fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h create mode 100644 fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp create mode 100644 fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h create mode 100644 fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index f8cf4dadbd293..ab46dac2e6fcd 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -3,6 +3,13 @@ # In general, libraries and binaries in fboss/foo/bar are built by # cmake/FooBar.cmake +add_fbthrift_cpp_library( + cli_metadata + fboss/cli/fboss2/cli_metadata.thrift + OPTIONS + json +) + add_fbthrift_cpp_library( cli_model fboss/cli/fboss2/cli.thrift @@ -588,6 +595,9 @@ add_library(fboss2_config_lib 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/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/history/CmdConfigHistory.h fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h @@ -605,6 +615,7 @@ add_library(fboss2_config_lib ) target_link_libraries(fboss2_config_lib + cli_metadata fboss2_lib agent_dir_util ) diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index b868a398f2fc8..f3f68a8f20401 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -38,6 +38,7 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp + fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp fboss/cli/fboss2/test/CmdConfigReloadTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp fboss/cli/fboss2/test/CmdConfigSessionTest.cpp diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 35a21bd461767..7563b64c11f63 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -15,6 +15,15 @@ thrift_library( thrift_srcs = {"cli.thrift": []}, ) +thrift_library( + name = "cli_metadata", + languages = [ + "cpp2", + ], + thrift_cpp2_options = "json", + thrift_srcs = {"cli_metadata.thrift": []}, +) + # NOTE: all of the actual command tree is managed inside CmdList.cpp # CmdList.h defines the data structure cpp_library( @@ -792,6 +801,7 @@ cpp_library( "commands/config/history/CmdConfigHistory.cpp", "commands/config/interface/CmdConfigInterfaceDescription.cpp", "commands/config/interface/CmdConfigInterfaceMtu.cpp", + "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", @@ -805,6 +815,8 @@ cpp_library( "commands/config/interface/CmdConfigInterface.h", "commands/config/interface/CmdConfigInterfaceDescription.h", "commands/config/interface/CmdConfigInterfaceMtu.h", + "commands/config/qos/CmdConfigQos.h", + "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h", "commands/config/rollback/CmdConfigRollback.h", "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", @@ -815,6 +827,7 @@ cpp_library( "fbsource//third-party/fmt:fmt", "fbsource//third-party/glog:glog", "fbsource//third-party/re2:re2", + ":cli_metadata-cpp2-types", ":cmd-common-utils", ":cmd-handler", ":fboss2-lib", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 7822a84641ab5..290831c6c86be 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -19,6 +19,8 @@ #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" @@ -40,5 +42,8 @@ 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/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 05e070f392c0c..78e4ad709d3ee 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -17,6 +17,8 @@ #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" @@ -57,6 +59,20 @@ const CommandTree& kConfigCommandTree() { }}, }, + { + "config", + "qos", + "Configure QoS settings", + commandHandler, + argTypeHandler, + {{ + "buffer-pool", + "Configure buffer pool settings", + commandHandler, + argTypeHandler, + }}, + }, + { "config", "session", diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 94bcd70ad244c..24221d3d6b753 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -232,6 +232,13 @@ CLI::App* CmdSubcommands::addCommand( subCmd->add_option( "revisions", args, "Revision(s) in the form 'rN' or 'current'"); break; + case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME: + subCmd->add_option( + "buffer_pool_config", + args, + " [ ...] where is one " + "of: shared-bytes, headroom-bytes, reserved-bytes"); + break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; diff --git a/fboss/cli/fboss2/cli_metadata.thrift b/fboss/cli/fboss2/cli_metadata.thrift new file mode 100644 index 0000000000000..18b82b687d92e --- /dev/null +++ b/fboss/cli/fboss2/cli_metadata.thrift @@ -0,0 +1,35 @@ +/* + * 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. + * + */ + +package "facebook.com/fboss/cli" + +namespace cpp2 facebook.fboss.cli + +// Action level required for config changes to take effect. +// Used to track the highest impact action needed when committing config +// changes. +enum ConfigActionLevel { + HITLESS = 0, // Can be applied with reloadConfig() - default + AGENT_RESTART = 1, // Requires agent restart +} + +// Identifier for different agents that can be configured +enum AgentType { + WEDGE_AGENT = 1, +} + +// Metadata stored alongside the session configuration file. +// This metadata tracks state that needs to persist across CLI invocations +// within a single config session. +struct ConfigSessionMetadata { + // Maps each agent to the required action level for pending config changes. + // Agents not in this map default to HITLESS. + 1: map action; +} diff --git a/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h b/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h new file mode 100644 index 0000000000000..382bcf7219879 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/CmdConfigQos.h @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" + +namespace facebook::fboss { + +struct CmdConfigQosTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdConfigQos : public CmdHandler { + public: + RetType queryClient(const HostInfo& /* hostInfo */) { + 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/qos/buffer_pool/CmdConfigQosBufferPool.cpp b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp new file mode 100644 index 0000000000000..25b5b131d24d8 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp @@ -0,0 +1,155 @@ +/* + * 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/buffer_pool/CmdConfigQosBufferPool.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "fboss/agent/gen-cpp2/switch_config_types.h" +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +namespace { + +const std::set kValidAttrs = { + "shared-bytes", + "headroom-bytes", + "reserved-bytes", +}; + +void validatePoolName(const std::string& name) { + // Valid pool name: starts with letter, alphanumeric + underscore/hyphen, + // 1-64 chars + static const re2::RE2 kValidPoolNamePattern("^[a-zA-Z][a-zA-Z0-9_-]{0,63}$"); + if (!re2::RE2::FullMatch(name, kValidPoolNamePattern)) { + throw std::invalid_argument( + "Invalid buffer pool name: '" + name + + "'. Name must start with a letter, contain only alphanumeric " + "characters, underscores, or hyphens, and be 1-64 characters long."); + } +} + +int32_t parseBytes(const std::string& value) { + try { + int32_t bytes = folly::to(value); + if (bytes < 0) { + throw std::invalid_argument( + fmt::format("Bytes value must be non-negative, got: {}", bytes)); + } + return bytes; + } catch (const folly::ConversionError&) { + throw std::invalid_argument(fmt::format("Invalid bytes value: {}", value)); + } +} + +} // namespace + +BufferPoolConfig::BufferPoolConfig(std::vector v) { + if (v.empty()) { + throw std::invalid_argument( + "Expected: [ ...] where is " + "one of: shared-bytes, headroom-bytes, reserved-bytes"); + } + + // First argument is the pool name + name_ = v[0]; + validatePoolName(name_); + data_.push_back(name_); + + // Remaining arguments are key-value pairs + if (v.size() < 3) { + throw std::invalid_argument( + "Expected at least one attribute-value pair after pool name. " + "Valid attributes are: shared-bytes, headroom-bytes, reserved-bytes"); + } + + if ((v.size() - 1) % 2 != 0) { + throw std::invalid_argument( + "Attribute-value pairs must come in pairs. Got odd number of " + "arguments after pool name."); + } + + for (size_t i = 1; 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); + } +} + +CmdConfigQosBufferPoolTraits::RetType CmdConfigQosBufferPool::queryClient( + const HostInfo& /* hostInfo */, + const ObjectArgType& config) { + const std::string& poolName = config.getName(); + + auto& session = ConfigSession::getInstance(); + auto& agentConfig = session.getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + + // Get or create the bufferPoolConfigs map + if (!switchConfig.bufferPoolConfigs()) { + switchConfig.bufferPoolConfigs() = + std::map{}; + } + + auto& bufferPoolConfigs = *switchConfig.bufferPoolConfigs(); + + // Get or create the buffer pool config + auto it = bufferPoolConfigs.find(poolName); + if (it == bufferPoolConfigs.end()) { + // Create a new buffer pool config with default sharedBytes + cfg::BufferPoolConfig newConfig; + newConfig.sharedBytes() = 0; + bufferPoolConfigs[poolName] = std::move(newConfig); + it = bufferPoolConfigs.find(poolName); + } + + cfg::BufferPoolConfig& targetConfig = it->second; + + // Process each attribute-value pair + for (const auto& [attr, value] : config.getAttributes()) { + int32_t bytes = parseBytes(value); + if (attr == "shared-bytes") { + targetConfig.sharedBytes() = bytes; + } else if (attr == "headroom-bytes") { + targetConfig.headroomBytes() = bytes; + } else if (attr == "reserved-bytes") { + targetConfig.reservedBytes() = bytes; + } + } + + // Save the updated config - buffer pool changes require agent restart + session.saveConfig(cli::ConfigActionLevel::AGENT_RESTART); + + return fmt::format("Successfully configured buffer-pool '{}'", poolName); +} + +void CmdConfigQosBufferPool::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h new file mode 100644 index 0000000000000..6a79242ba0f2a --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h @@ -0,0 +1,67 @@ +/* + * 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/CmdConfigQos.h" +#include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" + +namespace facebook::fboss { + +// Custom type for buffer pool configuration +// Parses: [ ...] +// where attr is one of: shared-bytes, headroom-bytes, reserved-bytes +class BufferPoolConfig : public utils::BaseObjectArgType { + public: + // NOLINTNEXTLINE(google-explicit-constructor) + /* implicit */ BufferPoolConfig(std::vector v); + + const std::string& getName() const { + return name_; + } + + const std::vector>& getAttributes() + const { + return attributes_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME; + + private: + std::string name_; + std::vector> attributes_; +}; + +struct CmdConfigQosBufferPoolTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigQos; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME; + using ObjectArgType = BufferPoolConfig; + using RetType = std::string; +}; + +class CmdConfigQosBufferPool + : public CmdHandler { + public: + using ObjectArgType = CmdConfigQosBufferPoolTraits::ObjectArgType; + using RetType = CmdConfigQosBufferPoolTraits::RetType; + + RetType queryClient(const HostInfo& hostInfo, const ObjectArgType& config); + + 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 eef1f9e4cae7e..9c40594da2320 100644 --- a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp @@ -9,6 +9,8 @@ */ #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" + +#include #include "fboss/cli/fboss2/session/ConfigSession.h" namespace facebook::fboss { @@ -21,9 +23,20 @@ CmdConfigSessionCommitTraits::RetType CmdConfigSessionCommit::queryClient( return "No config session exists. Make a config change first."; } - int revision = session.commit(hostInfo); - return "Config session committed successfully as r" + - std::to_string(revision) + " and config reloaded."; + auto result = session.commit(hostInfo); + + std::string message; + if (result.actionLevel == cli::ConfigActionLevel::AGENT_RESTART) { + message = fmt::format( + "Config session committed successfully as r{} and wedge_agent restarted.", + result.revision); + } else { + message = fmt::format( + "Config session committed successfully as r{} and config reloaded.", + result.revision); + } + + return message; } void CmdConfigSessionCommit::printOutput(const RetType& logMsg) { diff --git a/fboss/cli/fboss2/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index a9a4d959fb124..1bb720f1cf4f6 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -16,19 +16,24 @@ #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/cli/fboss2/gen-cpp2/cli_metadata_types.h" #include "fboss/cli/fboss2/utils/CmdClientUtils.h" #include "fboss/cli/fboss2/utils/PortMap.h" @@ -281,7 +286,9 @@ const utils::PortMap& ConfigSession::getPortMap() const { return *portMap_; } -void ConfigSession::saveConfig() { +void ConfigSession::saveConfig( + std::optional actionLevel, + cli::AgentType agent) { if (!configLoaded_) { throw std::runtime_error("No config loaded to save"); } @@ -303,6 +310,11 @@ void ConfigSession::saveConfig() { // seeing partial/corrupted data. folly::writeFileAtomic( sessionConfigPath_, prettyJson, 0644, folly::SyncType::WITH_SYNC); + + // If an action level was provided, update the required action metadata + if (actionLevel.has_value()) { + updateRequiredAction(*actionLevel, agent); + } } int ConfigSession::extractRevisionNumber(const std::string& filenameOrPath) { @@ -324,6 +336,158 @@ int ConfigSession::extractRevisionNumber(const std::string& filenameOrPath) { return -1; } +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(); +} + +std::string ConfigSession::getServiceName(cli::AgentType agent) { + switch (agent) { + case cli::AgentType::WEDGE_AGENT: + return "wedge_agent"; + } + throw std::runtime_error("Unknown agent type"); +} + +void ConfigSession::loadActionLevel() { + std::string metadataPath = getMetadataPath(); + // Note: We don't initialize requiredActions_ here since getRequiredAction() + // returns HITLESS by default for agents not in the map, and + // updateRequiredAction() handles adding new agents. + + if (!fs::exists(metadataPath)) { + return; + } + + std::string content; + if (!folly::readFile(metadataPath.c_str(), content)) { + // If we can't read the file, keep defaults + return; + } + + // Parse JSON with symbolic enum names using fbthrift's folly_dynamic API + // LENIENT adherence allows parsing both string names and integer values + try { + folly::dynamic json = folly::parseJson(content); + cli::ConfigSessionMetadata metadata; + facebook::thrift::from_dynamic( + metadata, + json, + facebook::thrift::dynamic_format::PORTABLE, + facebook::thrift::format_adherence::LENIENT); + requiredActions_ = *metadata.action(); + } catch (const std::exception& ex) { + // If JSON parsing fails, keep defaults + LOG(WARNING) << "Failed to parse metadata file: " << ex.what(); + } +} + +void ConfigSession::saveActionLevel() { + 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_; + + folly::dynamic json = facebook::thrift::to_dynamic( + metadata, facebook::thrift::dynamic_format::PORTABLE); + std::string prettyJson = folly::toPrettyJson(json); + folly::writeFileAtomic( + metadataPath, prettyJson, 0644, folly::SyncType::WITH_SYNC); +} + +void ConfigSession::updateRequiredAction( + cli::ConfigActionLevel actionLevel, + cli::AgentType agent) { + // Initialize to HITLESS if not present + if (requiredActions_.find(agent) == requiredActions_.end()) { + requiredActions_[agent] = cli::ConfigActionLevel::HITLESS; + } + + // Only update if the new action level is higher (more impactful) + if (static_cast(actionLevel) > + static_cast(requiredActions_[agent])) { + requiredActions_[agent] = actionLevel; + saveActionLevel(); + } +} + +cli::ConfigActionLevel ConfigSession::getRequiredAction( + cli::AgentType agent) const { + auto it = requiredActions_.find(agent); + if (it != requiredActions_.end()) { + return it->second; + } + return cli::ConfigActionLevel::HITLESS; +} + +void ConfigSession::resetRequiredAction(cli::AgentType agent) { + requiredActions_[agent] = cli::ConfigActionLevel::HITLESS; + + // If all agents are HITLESS, remove the file entirely + bool allHitless = true; + for (const auto& [a, level] : requiredActions_) { + if (level != cli::ConfigActionLevel::HITLESS) { + allHitless = false; + break; + } + } + if (allHitless) { + std::string metadataPath = getMetadataPath(); + std::error_code ec; + fs::remove(metadataPath, ec); + // Ignore errors - file might not exist + } else { + // Only save if there are remaining agents with non-HITLESS levels + saveActionLevel(); + } +} + +void ConfigSession::restartAgent(cli::AgentType agent) { + std::string serviceName = getServiceName(agent); + + LOG(INFO) << "Restarting " << serviceName << " via systemd..."; + + // Use systemctl to restart the service + // Using folly::Subprocess with explicit args avoids shell injection + try { + folly::Subprocess restartProc( + {"/usr/bin/systemctl", "restart", serviceName}); + restartProc.waitChecked(); + } catch (const std::exception& ex) { + throw std::runtime_error( + fmt::format("Failed to restart {}: {}", serviceName, ex.what())); + } + + // Wait for the service to be active (up to 60 seconds) + constexpr int maxWaitSeconds = 60; + constexpr int pollIntervalMs = 500; + int waitedMs = 0; + + while (waitedMs < maxWaitSeconds * 1000) { + try { + folly::Subprocess checkProc( + {"/usr/bin/systemctl", "is-active", "--quiet", serviceName}); + checkProc.waitChecked(); + // If waitChecked() doesn't throw, the service is active + LOG(INFO) << serviceName << " is now active"; + return; + } catch (const folly::CalledProcessError&) { + // Service not active yet, keep waiting + } + std::this_thread::sleep_for(std::chrono::milliseconds(pollIntervalMs)); + waitedMs += pollIntervalMs; + } + + throw std::runtime_error( + fmt::format( + "{} did not become active within {} seconds", + serviceName, + maxWaitSeconds)); +} + void ConfigSession::loadConfig() { std::string configJson; if (!folly::readFile(sessionConfigPath_.c_str(), configJson)) { @@ -350,6 +514,8 @@ void ConfigSession::initializeSession() { ensureDirectoryExists(sessionPath.parent_path().string()); copySystemConfigToSession(); } + // Load the action level from disk (survives across CLI invocations) + loadActionLevel(); } void ConfigSession::copySystemConfigToSession() { @@ -388,7 +554,7 @@ void ConfigSession::copySystemConfigToSession() { sessionConfigPath_, configData, 0644, folly::SyncType::WITH_SYNC); } -int ConfigSession::commit(const HostInfo& hostInfo) { +ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { if (!sessionExists()) { throw std::runtime_error( "No config session exists. Make a config change first."); @@ -437,28 +603,46 @@ int ConfigSession::commit(const HostInfo& hostInfo) { // Atomically update the symlink to point to the new config atomicSymlinkUpdate(systemConfigPath_, targetConfigPath); - // Reload the config - if this fails, rollback the symlink atomically + // Check the required action level for this commit + auto actionLevel = getRequiredAction(cli::AgentType::WEDGE_AGENT); + + // Apply the config based on the required action level try { - auto client = - utils::createClient>( - hostInfo); - client->sync_reloadConfig(); - LOG(INFO) << "Config committed as revision r" << revision; + if (actionLevel == cli::ConfigActionLevel::AGENT_RESTART) { + // For AGENT_RESTART changes, restart the agent via systemd + // This will cause the agent to pick up the new config on startup + restartAgent(cli::AgentType::WEDGE_AGENT); + LOG(INFO) << "Config committed as revision r" << revision + << " (agent restarted)"; + } else { + // For HITLESS changes, use reloadConfig() which applies without restart + auto client = utils::createClient< + apache::thrift::Client>(hostInfo); + client->sync_reloadConfig(); + LOG(INFO) << "Config committed as revision r" << revision + << " (config reloaded)"; + } } catch (const std::exception& ex) { // Rollback: atomically restore the old symlink try { atomicSymlinkUpdate(systemConfigPath_, oldSymlinkTarget); + // If this was an AGENT_RESTART change, we need to restart the agent again + // so it picks up the old config (in case the restart was partially + // successful before failing) + if (actionLevel == cli::ConfigActionLevel::AGENT_RESTART) { + restartAgent(cli::AgentType::WEDGE_AGENT); + } } 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 config: {}", + "Failed to apply config: {}. Additionally, failed to rollback the config: {}", ex.what(), rollbackEx.what())); } throw std::runtime_error( fmt::format( - "Failed to reload config, config was rolled back automatically: {}", + "Failed to apply config, config was rolled back automatically: {}", ex.what())); } @@ -472,7 +656,10 @@ int ConfigSession::commit(const HostInfo& hostInfo) { ec.message()); } - return revision; + // Reset action level after successful commit + resetRequiredAction(cli::AgentType::WEDGE_AGENT); + + return CommitResult{revision, actionLevel}; } int ConfigSession::rollback(const HostInfo& hostInfo) { @@ -498,12 +685,14 @@ int ConfigSession::rollback( ensureDirectoryExists(cliConfigDir_); // Build the path to the target revision - std::string targetConfigPath = cliConfigDir_ + "/agent-" + revision + ".conf"; + std::string targetConfigPath = + fmt::format("{}/agent-{}.conf", cliConfigDir_, revision); // Check if the target revision exists if (!fs::exists(targetConfigPath)) { throw std::runtime_error( - "Revision " + revision + " does not exist at " + targetConfigPath); + fmt::format( + "Revision {} does not exist at {}", revision, targetConfigPath)); } std::error_code ec; @@ -511,14 +700,17 @@ int ConfigSession::rollback( // Verify that the system config is a symlink if (!fs::is_symlink(systemConfigPath_)) { throw std::runtime_error( - systemConfigPath_ + " is not a symlink. Expected it to be a symlink."); + fmt::format( + "{} is not a symlink. Expected it to be a symlink.", + systemConfigPath_)); } // Read the old symlink target in case we need to undo the rollback std::string oldSymlinkTarget = fs::read_symlink(systemConfigPath_, ec); if (ec) { throw std::runtime_error( - "Failed to read symlink " + systemConfigPath_ + ": " + ec.message()); + fmt::format( + "Failed to read symlink {}: {}", systemConfigPath_, ec.message())); } // First, create a new revision with the same content as the target revision diff --git a/fboss/cli/fboss2/session/ConfigSession.h b/fboss/cli/fboss2/session/ConfigSession.h index 7838262eaae35..4e67e769366a0 100644 --- a/fboss/cli/fboss2/session/ConfigSession.h +++ b/fboss/cli/fboss2/session/ConfigSession.h @@ -9,10 +9,13 @@ #pragma once +#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/utils/HostInfo.h" namespace facebook::fboss::utils { @@ -96,11 +99,21 @@ class ConfigSession { // Get the path to the CLI config directory (/etc/coop/cli) std::string getCliConfigDir() const; + // Result of a commit operation + struct CommitResult { + int revision; // The revision number that was committed + cli::ConfigActionLevel actionLevel; // The action level that was required + // Note: configReloaded can be inferred from actionLevel: + // - HITLESS: config was reloaded via reloadConfig() + // - AGENT_RESTART: agent was restarted via systemd + }; + // Atomically commit the session to /etc/coop/cli/agent-rN.conf, - // update the symlink /etc/coop/agent.conf to point to it, and reload config. - // Returns the revision number that was committed if the commit was - // successful. - int commit(const HostInfo& hostInfo); + // 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. + CommitResult commit(const HostInfo& hostInfo); // Rollback to a specific revision or to the previous revision // Returns the revision that was rolled back to @@ -118,13 +131,36 @@ class ConfigSession { utils::PortMap& getPortMap(); const utils::PortMap& getPortMap() const; - // Save the configuration back to the session file - void saveConfig(); + // Save the configuration back to the session file. + // If actionLevel is provided, also updates the required action level + // for the specified agent (if the new level is higher than the current one). + // This combines saving the config and updating its associated metadata. + void saveConfig( + std::optional actionLevel = std::nullopt, + cli::AgentType agent = cli::AgentType::WEDGE_AGENT); // 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); + // Update the required action level for the current session. + // Tracks the highest action level across all config commands. + // Higher action levels take precedence (AGENT_RESTART > HITLESS). + // The agent parameter specifies which agent this action level applies to. + // Currently only WEDGE_AGENT is supported; future agents will be added. + void updateRequiredAction( + cli::ConfigActionLevel actionLevel, + cli::AgentType agent = cli::AgentType::WEDGE_AGENT); + + // Get the current required action level for the session + // The agent parameter specifies which agent to get the action level for. + cli::ConfigActionLevel getRequiredAction( + cli::AgentType agent = cli::AgentType::WEDGE_AGENT) const; + + // Reset the required action level to HITLESS (called after successful commit) + // The agent parameter specifies which agent to reset the action level for. + void resetRequiredAction(cli::AgentType agent = cli::AgentType::WEDGE_AGENT); + protected: // Constructor for testing with custom paths ConfigSession( @@ -146,6 +182,24 @@ class ConfigSession { std::unique_ptr portMap_; bool configLoaded_ = false; + // Track the highest action level required for pending config changes per + // agent. Persisted to disk so it survives across CLI invocations within a + // session. + std::map requiredActions_; + + // Path to the metadata file (e.g., ~/.fboss2/metadata) + std::string getMetadataPath() const; + + // Load/save action levels from/to disk + void loadActionLevel(); + void saveActionLevel(); + + // Restart an agent via systemd and wait for it to be active + void restartAgent(cli::AgentType agent); + + // Get the systemd service name for an agent + static std::string getServiceName(cli::AgentType agent); + // Initialize the session (creates session config file if it doesn't exist) void initializeSession(); void copySystemConfigToSession(); diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index a88ce899d0ab0..8ba400a718b87 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -67,6 +67,7 @@ cpp_unittest( "CmdConfigHistoryTest.cpp", "CmdConfigInterfaceDescriptionTest.cpp", "CmdConfigInterfaceMtuTest.cpp", + "CmdConfigQosBufferPoolTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", diff --git a/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp b/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp new file mode 100644 index 0000000000000..b5dc507888064 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigQosBufferPoolTest.cpp @@ -0,0 +1,311 @@ +/* + * 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/qos/buffer_pool/CmdConfigQosBufferPool.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" + +namespace fs = std::filesystem; + +namespace facebook::fboss { + +class CmdConfigQosBufferPoolTestFixture : 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_bp_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"); + + // Set environment variables + setenv("HOME", testHomeDir_.c_str(), 1); + setenv("USER", "testuser", 1); + + // Create a test system config file + fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; + createTestConfig(initialRevision, R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000 + } + ] + } +})"); + + // Create symlink + systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; + fs::create_symlink(initialRevision, systemConfigPath_); + + // Create session config path + sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; + cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + } + + 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(); + } + + std::string readFile(const fs::path& path) { + std::ifstream file(path); + std::stringstream buffer; + buffer << file.rdbuf(); + return buffer.str(); + } + + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigPath_; + fs::path sessionConfigPath_; + fs::path cliConfigDir_; +}; + +// Test BufferPoolConfig argument validation +TEST_F(CmdConfigQosBufferPoolTestFixture, bufferPoolConfigValidation) { + // Valid config with one attribute + EXPECT_NO_THROW(BufferPoolConfig({"ingress_pool", "shared-bytes", "1000"})); + EXPECT_NO_THROW( + BufferPoolConfig({"egress-lossy-pool", "headroom-bytes", "2000"})); + EXPECT_NO_THROW(BufferPoolConfig({"Pool1", "reserved-bytes", "3000"})); + + // Valid config with multiple attributes + EXPECT_NO_THROW(BufferPoolConfig( + {"test_pool", "shared-bytes", "1000", "headroom-bytes", "2000"})); + EXPECT_NO_THROW(BufferPoolConfig( + {"test_pool", + "shared-bytes", + "1000", + "headroom-bytes", + "2000", + "reserved-bytes", + "3000"})); + + // Empty should throw + EXPECT_THROW(BufferPoolConfig({}), std::invalid_argument); + + // Name only (no attributes) should throw + EXPECT_THROW(BufferPoolConfig({"pool1"}), std::invalid_argument); + + // Name with only one arg (odd number after name) should throw + EXPECT_THROW( + BufferPoolConfig({"pool1", "shared-bytes"}), std::invalid_argument); + + // Invalid pool names - must start with letter + EXPECT_THROW( + BufferPoolConfig({"123pool", "shared-bytes", "1000"}), + std::invalid_argument); + EXPECT_THROW( + BufferPoolConfig({"_pool", "shared-bytes", "1000"}), + std::invalid_argument); + + // Invalid attribute name + EXPECT_THROW( + BufferPoolConfig({"pool1", "invalid-attr", "1000"}), + std::invalid_argument); + + // Note: Invalid bytes values (negative, non-numeric) are validated in + // queryClient, not in the constructor. This allows the command to be + // constructed and provide better error messages at execution time. +} + +// Test BufferPoolConfig getters +TEST_F(CmdConfigQosBufferPoolTestFixture, bufferPoolConfigGetters) { + BufferPoolConfig config( + {"test_pool", "shared-bytes", "1000", "headroom-bytes", "2000"}); + + EXPECT_EQ(config.getName(), "test_pool"); + + const auto& attrs = config.getAttributes(); + ASSERT_EQ(attrs.size(), 2); + EXPECT_EQ(attrs[0].first, "shared-bytes"); + EXPECT_EQ(attrs[0].second, "1000"); + EXPECT_EQ(attrs[1].first, "headroom-bytes"); + EXPECT_EQ(attrs[1].second, "2000"); +} + +// Test shared-bytes command creates buffer pool config +TEST_F(CmdConfigQosBufferPoolTestFixture, sharedBytesCreatesBufferPool) { + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string())); + + auto cmd = CmdConfigQosBufferPool(); + BufferPoolConfig config({"test_pool", "shared-bytes", "50000"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, ::testing::HasSubstr("Successfully configured")); + EXPECT_THAT(result, ::testing::HasSubstr("test_pool")); + + // Verify the config was actually modified + auto& agentConfig = ConfigSession::getInstance().getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + ASSERT_TRUE(switchConfig.bufferPoolConfigs().has_value()); + + auto& bufferPoolConfigs = *switchConfig.bufferPoolConfigs(); + auto it = bufferPoolConfigs.find("test_pool"); + ASSERT_NE(it, bufferPoolConfigs.end()); + EXPECT_EQ(*it->second.sharedBytes(), 50000); +} + +// Test headroom-bytes command creates buffer pool config +TEST_F(CmdConfigQosBufferPoolTestFixture, headroomBytesCreatesBufferPool) { + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string())); + + auto cmd = CmdConfigQosBufferPool(); + BufferPoolConfig config({"headroom_pool", "headroom-bytes", "10000"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, ::testing::HasSubstr("Successfully configured")); + EXPECT_THAT(result, ::testing::HasSubstr("headroom_pool")); + + // Verify the config was actually modified + auto& agentConfig = ConfigSession::getInstance().getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + ASSERT_TRUE(switchConfig.bufferPoolConfigs().has_value()); + + auto& bufferPoolConfigs = *switchConfig.bufferPoolConfigs(); + auto it = bufferPoolConfigs.find("headroom_pool"); + ASSERT_NE(it, bufferPoolConfigs.end()); + EXPECT_EQ(*it->second.sharedBytes(), 0); // Default value + ASSERT_TRUE(it->second.headroomBytes().has_value()); + EXPECT_EQ(*it->second.headroomBytes(), 10000); +} + +// Test reserved-bytes command creates buffer pool config +TEST_F(CmdConfigQosBufferPoolTestFixture, reservedBytesCreatesBufferPool) { + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string())); + + auto cmd = CmdConfigQosBufferPool(); + BufferPoolConfig config({"reserved_pool", "reserved-bytes", "20000"}); + + auto result = cmd.queryClient(localhost(), config); + + EXPECT_THAT(result, ::testing::HasSubstr("Successfully configured")); + EXPECT_THAT(result, ::testing::HasSubstr("reserved_pool")); + + // Verify the config was actually modified + auto& agentConfig = ConfigSession::getInstance().getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + ASSERT_TRUE(switchConfig.bufferPoolConfigs().has_value()); + + auto& bufferPoolConfigs = *switchConfig.bufferPoolConfigs(); + auto it = bufferPoolConfigs.find("reserved_pool"); + ASSERT_NE(it, bufferPoolConfigs.end()); + EXPECT_EQ(*it->second.sharedBytes(), 0); // Default value + ASSERT_TRUE(it->second.reservedBytes().has_value()); + EXPECT_EQ(*it->second.reservedBytes(), 20000); +} + +// Test updating an existing buffer pool with multiple attributes +TEST_F(CmdConfigQosBufferPoolTestFixture, updateExistingBufferPool) { + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string())); + + auto cmd = CmdConfigQosBufferPool(); + + // Set all attributes in one command + BufferPoolConfig config( + {"existing_pool", + "shared-bytes", + "30000", + "headroom-bytes", + "5000", + "reserved-bytes", + "2000"}); + cmd.queryClient(localhost(), config); + + // Verify all values are set correctly + auto& agentConfig = ConfigSession::getInstance().getAgentConfig(); + auto& switchConfig = *agentConfig.sw(); + ASSERT_TRUE(switchConfig.bufferPoolConfigs().has_value()); + + auto& bufferPoolConfigs = *switchConfig.bufferPoolConfigs(); + auto it = bufferPoolConfigs.find("existing_pool"); + ASSERT_NE(it, bufferPoolConfigs.end()); + EXPECT_EQ(*it->second.sharedBytes(), 30000); + ASSERT_TRUE(it->second.headroomBytes().has_value()); + EXPECT_EQ(*it->second.headroomBytes(), 5000); + ASSERT_TRUE(it->second.reservedBytes().has_value()); + EXPECT_EQ(*it->second.reservedBytes(), 2000); +} + +// Test printOutput for buffer-pool command +TEST_F(CmdConfigQosBufferPoolTestFixture, printOutputBufferPool) { + auto cmd = CmdConfigQosBufferPool(); + std::string successMessage = "Successfully configured buffer-pool 'my_pool'"; + + 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/CmdConfigSessionTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp index 1dfb390be1ef0..b04b7decb27e0 100644 --- a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp @@ -207,13 +207,13 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { session.saveConfig(); // Commit the session - int revision = session.commit(localhost()); + auto result = session.commit(localhost()); // Verify session config no longer exists (removed after commit) EXPECT_FALSE(fs::exists(sessionConfig)); // Verify new revision was created in cli directory - EXPECT_EQ(revision, 2); + 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")); @@ -241,10 +241,10 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { session.saveConfig(); // Commit the second change - int revision = session.commit(localhost()); + auto result = session.commit(localhost()); // Verify new revision was created - EXPECT_EQ(revision, 3); + 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")); @@ -402,7 +402,7 @@ TEST_F(ConfigSessionTestFixture, atomicRevisionCreation) { ports[0].description() = description; session.saveConfig(); - rev = session.commit(localhost()); + rev = session.commit(localhost()).revision; }; std::thread thread1( @@ -475,7 +475,7 @@ TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { ports[0].description() = description; session.saveConfig(); - rev = session.commit(localhost()); + rev = session.commit(localhost()).revision; }; std::thread thread1(commitTask, "First commit", std::ref(revision1)); @@ -637,4 +637,173 @@ TEST_F(ConfigSessionTestFixture, rollbackToPreviousRevision) { EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.conf")); } +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()); + + // Default action level should be HITLESS + EXPECT_EQ( + session.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::HITLESS); +} + +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()); + + // Update to AGENT_RESTART + session.updateRequiredAction( + cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + + // Verify the action level was updated + EXPECT_EQ( + session.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::AGENT_RESTART); +} + +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()); + + // Update to AGENT_RESTART first + session.updateRequiredAction( + cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + + // Try to "downgrade" to HITLESS - should be ignored + session.updateRequiredAction( + cli::ConfigActionLevel::HITLESS, cli::AgentType::WEDGE_AGENT); + + // Verify action level remains at AGENT_RESTART + EXPECT_EQ( + session.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::AGENT_RESTART); +} + +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()); + + // Set to AGENT_RESTART + session.updateRequiredAction( + cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + + // Reset the action level + session.resetRequiredAction(cli::AgentType::WEDGE_AGENT); + + // Verify action level was reset to HITLESS + EXPECT_EQ( + session.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::HITLESS); +} + +TEST_F(ConfigSessionTestFixture, actionLevelPersistsToMetadataFile) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "conf_metadata.json"; + + // Create a ConfigSession and set action level + { + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").string()); + + // Set to AGENT_RESTART + session.updateRequiredAction( + cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + } + + // Verify metadata file exists and has correct JSON format + EXPECT_TRUE(fs::exists(metadataFile)); + std::string content = readFile(metadataFile); + + // Parse the JSON and verify structure - uses symbolic enum names + folly::dynamic json = folly::parseJson(content); + EXPECT_TRUE(json.isObject()); + EXPECT_TRUE(json.count("action")); + EXPECT_TRUE(json["action"].isObject()); + EXPECT_TRUE(json["action"].count("WEDGE_AGENT")); + EXPECT_EQ(json["action"]["WEDGE_AGENT"].asString(), "AGENT_RESTART"); +} + +TEST_F(ConfigSessionTestFixture, actionLevelLoadsFromMetadataFile) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "conf_metadata.json"; + + // Create session directory and metadata file manually + fs::create_directories(sessionDir); + std::ofstream metaFile(metadataFile); + // Use symbolic enum names for human readability + metaFile << R"({"action":{"WEDGE_AGENT":"AGENT_RESTART"}})"; + metaFile.close(); + + // Also create the session config file (otherwise session will overwrite from + // system) + fs::copy_file(systemConfigPath_, sessionConfig); + + // Create a ConfigSession - should load action level from metadata file + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").string()); + + // Verify action level was loaded + EXPECT_EQ( + session.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::AGENT_RESTART); +} + +TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + + // First session: set action level + { + TestableConfigSession session1( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").string()); + + session1.updateRequiredAction( + cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + } + + // Second session: verify action level was persisted + { + TestableConfigSession session2( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").string()); + + EXPECT_EQ( + session2.getRequiredAction(cli::AgentType::WEDGE_AGENT), + cli::ConfigActionLevel::AGENT_RESTART); + } +} + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 2f2eb34d5a614..53adaa39a9132 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -66,6 +66,7 @@ enum class ObjectArgTypeId : uint8_t { OBJECT_ARG_TYPE_MTU, OBJECT_ARG_TYPE_ID_INTERFACE_LIST, OBJECT_ARG_TYPE_ID_REVISION_LIST, + OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME, }; template From c64dd21c1bac9eb5e947c07267da8f2df001e785 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Mon, 5 Jan 2026 21:22:01 +0000 Subject: [PATCH 02/18] Add a `fboss2 config interface switchport access vlan ` command. This doesn't yet automatically create the VLAN if it doesn't exist. --- cmake/CliFboss2.cmake | 4 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 4 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 12 + fboss/cli/fboss2/CmdListConfig.cpp | 22 ++ fboss/cli/fboss2/CmdSubcommands.cpp | 3 + .../switchport/CmdConfigInterfaceSwitchport.h | 42 +++ .../CmdConfigInterfaceSwitchportAccess.h | 43 +++ ...CmdConfigInterfaceSwitchportAccessVlan.cpp | 54 ++++ .../CmdConfigInterfaceSwitchportAccessVlan.h | 82 ++++++ fboss/cli/fboss2/test/BUCK | 1 + ...onfigInterfaceSwitchportAccessVlanTest.cpp | 244 ++++++++++++++++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 1 + 13 files changed, 513 insertions(+) create mode 100644 fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h create mode 100644 fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h create mode 100644 fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp create mode 100644 fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h create mode 100644 fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index ab46dac2e6fcd..1c50cf34d25d7 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -595,6 +595,10 @@ add_library(fboss2_config_lib 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/switchport/CmdConfigInterfaceSwitchport.h + fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h + fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h + fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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 diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index f3f68a8f20401..fbd094efa1048 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -38,6 +38,7 @@ add_executable(fboss2_cmd_test 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/CmdConfigQosBufferPoolTest.cpp fboss/cli/fboss2/test/CmdConfigReloadTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 7563b64c11f63..b5672da637526 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -801,6 +801,7 @@ cpp_library( "commands/config/history/CmdConfigHistory.cpp", "commands/config/interface/CmdConfigInterfaceDescription.cpp", "commands/config/interface/CmdConfigInterfaceMtu.cpp", + "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", @@ -815,6 +816,9 @@ cpp_library( "commands/config/interface/CmdConfigInterface.h", "commands/config/interface/CmdConfigInterfaceDescription.h", "commands/config/interface/CmdConfigInterfaceMtu.h", + "commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h", + "commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h", + "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h", "commands/config/qos/CmdConfigQos.h", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h", "commands/config/rollback/CmdConfigRollback.h", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 290831c6c86be..515f4dbb239e4 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -19,6 +19,9 @@ #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/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/qos/CmdConfigQos.h" #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" @@ -36,6 +39,15 @@ template void CmdHandler< CmdConfigInterfaceDescriptionTraits>::run(); template void CmdHandler::run(); +template void CmdHandler< + CmdConfigInterfaceSwitchport, + CmdConfigInterfaceSwitchportTraits>::run(); +template void CmdHandler< + CmdConfigInterfaceSwitchportAccess, + CmdConfigInterfaceSwitchportAccessTraits>::run(); +template void CmdHandler< + CmdConfigInterfaceSwitchportAccessVlan, + CmdConfigInterfaceSwitchportAccessVlanTraits>::run(); template void CmdHandler::run(); template void CmdHandler::run(); template void diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 78e4ad709d3ee..697111d4daa13 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -17,6 +17,9 @@ #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/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/qos/CmdConfigQos.h" #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" @@ -56,6 +59,25 @@ const CommandTree& kConfigCommandTree() { "Set interface MTU", 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>, + }}, + }}, }}, }, diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 24221d3d6b753..f457b94d73513 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -239,6 +239,9 @@ 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_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..c32a5eb3e0f32 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.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/interface/CmdConfigInterface.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfaceList.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::InterfaceList& /* interfaces */) { + 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.h b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h new file mode 100644 index 0000000000000..0eb3ce70b3556 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfaceList.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::InterfaceList& /* interfaces */) { + 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..fe4cd1c17df53 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp @@ -0,0 +1,54 @@ +/* + * 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 +#include "fboss/cli/fboss2/session/ConfigSession.h" + +namespace facebook::fboss { + +CmdConfigInterfaceSwitchportAccessVlanTraits::RetType +CmdConfigInterfaceSwitchportAccessVlan::queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const CmdConfigInterfaceSwitchportAccessVlanTraits::ObjectArgType& + vlanIdValue) { + 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; +} + +} // 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..e2af310574b50 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include +#include +#include "fboss/cli/fboss2/CmdHandler.h" +#include "fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" +#include "fboss/cli/fboss2/utils/InterfaceList.h" + +namespace facebook::fboss { + +// Custom type for VLAN ID argument with validation +class VlanIdValue : public utils::BaseObjectArgType { + public: + /* implicit */ VlanIdValue(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 utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID; +}; + +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::InterfaceList& interfaces, + const ObjectArgType& vlanId); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index 8ba400a718b87..3c0398c53b8cb 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -67,6 +67,7 @@ cpp_unittest( "CmdConfigHistoryTest.cpp", "CmdConfigInterfaceDescriptionTest.cpp", "CmdConfigInterfaceMtuTest.cpp", + "CmdConfigInterfaceSwitchportAccessVlanTest.cpp", "CmdConfigQosBufferPoolTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp new file mode 100644 index 0000000000000..5a426a939b473 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp @@ -0,0 +1,244 @@ +/* + * 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 "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h" +#include "fboss/cli/fboss2/test/CmdHandlerTestBase.h" +#include "fboss/cli/fboss2/test/TestableConfigSession.h" +#include "fboss/cli/fboss2/utils/PortMap.h" + +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_); + fs::create_directories(testEtcDir_ / "coop"); + fs::create_directories(testEtcDir_ / "coop" / "cli"); + + // Set environment variables + setenv("HOME", testHomeDir_.c_str(), 1); + setenv("USER", "testuser", 1); + + // Create a test system config file with ports + fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; + createTestConfig(initialRevision, 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 + systemConfigPath_ = testEtcDir_ / "coop" / "agent.conf"; + fs::create_symlink(initialRevision, systemConfigPath_); + + // Create session config path + sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; + cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + } + + void TearDown() override { + 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_; + fs::path sessionConfigPath_; + fs::path cliConfigDir_; +}; + +// 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) { + TestableConfigSession session( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string()); + + auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); + VlanIdValue vlanId({"2001"}); + + // Create InterfaceList from port names + utils::InterfaceList interfaces({"eth1/1/1", "eth1/2/1"}); + + auto result = cmd.queryClient(localhost(), interfaces, 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& 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) { + TestableConfigSession session( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.string()); + + auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); + VlanIdValue vlanId({"100"}); + + // Empty InterfaceList is valid to construct but queryClient should throw + utils::InterfaceList emptyInterfaces({}); + EXPECT_THROW( + cmd.queryClient(localhost(), emptyInterfaces, vlanId), + std::invalid_argument); +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 53adaa39a9132..384deef7a7cdd 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -67,6 +67,7 @@ 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, }; template From 2ce7e5f21828a2c294722ebfb278c5ab0578f96c Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Fri, 9 Jan 2026 22:19:41 +0000 Subject: [PATCH 03/18] Add a unit tests checking the CLI command tree. A recent merge introduced a duplicate command by mistake (bad merge on my part) and this escaped because of lack of test coverage. Also make sure we keep `cmake/CliFboss2Test.cmake` sorted. --- cmake/CliFboss2Test.cmake | 4 ++- fboss/cli/fboss2/test/BUCK | 1 + fboss/cli/fboss2/test/CmdListConfigTest.cpp | 30 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 fboss/cli/fboss2/test/CmdListConfigTest.cpp diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index fbd094efa1048..f1d9dbcce2274 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -33,6 +33,7 @@ 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 @@ -43,6 +44,8 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/CmdConfigReloadTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp fboss/cli/fboss2/test/CmdConfigSessionTest.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 @@ -54,7 +57,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 diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index 3c0398c53b8cb..f2ec51dd9d63e 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -73,6 +73,7 @@ cpp_unittest( "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", "CmdGetPcapTest.cpp", + "CmdListConfigTest.cpp", "CmdSetPortStateTest.cpp", "CmdShowAclTest.cpp", "CmdShowAgentSslTest.cpp", 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 From 31eb90450944c33ed6803ce77253f058918d9af7 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Tue, 13 Jan 2026 17:08:09 +0000 Subject: [PATCH 04/18] Save the config commands to CLI metadata and in session commit. Now every config command is saved in the CLI session metadata so we can easily tell what commands were used in a given session. The metadata is now also saved along the config when we commit the session. A future commit will make rollback also rely on this metadata to decide whether or not to restart the agent. --- fboss/cli/fboss2/cli_metadata.thrift | 3 + fboss/cli/fboss2/session/ConfigSession.cpp | 97 +++++- fboss/cli/fboss2/session/ConfigSession.h | 16 +- .../cli/fboss2/test/CmdConfigSessionTest.cpp | 316 ++++++++++++++++-- fboss/cli/fboss2/test/TestableConfigSession.h | 3 + 5 files changed, 394 insertions(+), 41 deletions(-) diff --git a/fboss/cli/fboss2/cli_metadata.thrift b/fboss/cli/fboss2/cli_metadata.thrift index 18b82b687d92e..97247c320fc7d 100644 --- a/fboss/cli/fboss2/cli_metadata.thrift +++ b/fboss/cli/fboss2/cli_metadata.thrift @@ -32,4 +32,7 @@ struct ConfigSessionMetadata { // Maps each agent to the required action level for pending config changes. // Agents not in this map default to HITLESS. 1: map action; + // List of CLI commands executed in this session, in chronological order. + // Each entry is the full command string (e.g., "config interface eth1/1/1 mtu 9000"). + 2: list commands; } diff --git a/fboss/cli/fboss2/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index 1bb720f1cf4f6..45fccb8b571d9 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -191,6 +192,35 @@ int getCurrentRevisionNumber(const std::string& systemConfigPath) { return ConfigSession::extractRevisionNumber(target); } +/* + * 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" + */ +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))); + } + + 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); +} + } // anonymous namespace ConfigSession::ConfigSession() { @@ -298,7 +328,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_); @@ -311,10 +341,26 @@ void ConfigSession::saveConfig( folly::writeFileAtomic( sessionConfigPath_, prettyJson, 0644, folly::SyncType::WITH_SYNC); - // If an action level was provided, update the required action metadata + // Automatically record the command from /proc/self/cmdline. + // This ensures all config commands are tracked without requiring manual + // instrumentation in each command implementation. + std::string rawCmd = readCommandLineFromProc(); + CHECK(!rawCmd.empty()) + << "saveConfig() called with no command line arguments"; + // Only record if this is a config command and not already the last one + // recorded as that'd be idempotent anyway. + if (rawCmd.find("config ") == 0 && + (commands_.empty() || commands_.back() != rawCmd)) { + commands_.push_back(rawCmd); + } + + // If an action level was provided, update the required action level if (actionLevel.has_value()) { updateRequiredAction(*actionLevel, agent); } + + // Save command history and action levels to metadata + saveMetadata(); } int ConfigSession::extractRevisionNumber(const std::string& filenameOrPath) { @@ -350,7 +396,7 @@ std::string ConfigSession::getServiceName(cli::AgentType agent) { throw std::runtime_error("Unknown agent 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 @@ -377,19 +423,21 @@ void ConfigSession::loadActionLevel() { facebook::thrift::dynamic_format::PORTABLE, facebook::thrift::format_adherence::LENIENT); requiredActions_ = *metadata.action(); + commands_ = *metadata.commands(); } 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_; folly::dynamic json = facebook::thrift::to_dynamic( metadata, facebook::thrift::dynamic_format::PORTABLE); @@ -410,7 +458,6 @@ void ConfigSession::updateRequiredAction( if (static_cast(actionLevel) > static_cast(requiredActions_[agent])) { requiredActions_[agent] = actionLevel; - saveActionLevel(); } } @@ -425,6 +472,7 @@ cli::ConfigActionLevel ConfigSession::getRequiredAction( void ConfigSession::resetRequiredAction(cli::AgentType agent) { requiredActions_[agent] = cli::ConfigActionLevel::HITLESS; + commands_.clear(); // If all agents are HITLESS, remove the file entirely bool allHitless = true; @@ -441,7 +489,17 @@ void ConfigSession::resetRequiredAction(cli::AgentType agent) { // Ignore errors - file might not exist } else { // Only save if there are remaining agents with non-HITLESS levels - saveActionLevel(); + saveMetadata(); + } +} + +const std::vector& ConfigSession::getCommands() const { + return commands_; +} + +void ConfigSession::addCommand(const std::string& command) { + if (!command.empty() && (commands_.empty() || commands_.back() != command)) { + commands_.push_back(command); } } @@ -513,9 +571,12 @@ void ConfigSession::initializeSession() { fs::path sessionPath(sessionConfigPath_); ensureDirectoryExists(sessionPath.parent_path().string()); copySystemConfigToSession(); + // Create initial empty 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() { @@ -600,6 +661,23 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { ec.message())); } + // Copy the metadata file alongside the config revision + // e.g., agent-r123.conf -> agent-r123.metadata.json + // This is required for rollback functionality + std::string metadataPath = getMetadataPath(); + std::string targetMetadataPath = + fmt::format("{}/agent-r{}.metadata.json", cliConfigDir_, revision); + fs::copy_file(metadataPath, targetMetadataPath, ec); + if (ec) { + // Clean up the revision file we created + fs::remove(targetConfigPath); + throw std::runtime_error( + fmt::format( + "Failed to copy metadata to {}: {}", + targetMetadataPath, + ec.message())); + } + // Atomically update the symlink to point to the new config atomicSymlinkUpdate(systemConfigPath_, targetConfigPath); @@ -735,6 +813,9 @@ int ConfigSession::rollback( atomicSymlinkUpdate(systemConfigPath_, newRevisionPath); // Reload the config - if this fails, atomically undo the rollback + // 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. try { auto client = utils::createClient>( diff --git a/fboss/cli/fboss2/session/ConfigSession.h b/fboss/cli/fboss2/session/ConfigSession.h index 4e67e769366a0..32e14c83cb2ac 100644 --- a/fboss/cli/fboss2/session/ConfigSession.h +++ b/fboss/cli/fboss2/session/ConfigSession.h @@ -161,6 +161,9 @@ class ConfigSession { // The agent parameter specifies which agent to reset the action level for. void resetRequiredAction(cli::AgentType agent = cli::AgentType::WEDGE_AGENT); + // Get the list of commands executed in this session + const std::vector& getCommands() const; + protected: // Constructor for testing with custom paths ConfigSession( @@ -171,6 +174,10 @@ class ConfigSession { // 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_; @@ -187,12 +194,15 @@ class ConfigSession { // session. std::map requiredActions_; + // List of commands executed in this session, persisted to disk + std::vector commands_; + // Path to the metadata file (e.g., ~/.fboss2/metadata) 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 an agent via systemd and wait for it to be active void restartAgent(cli::AgentType agent); diff --git a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp index b04b7decb27e0..0f02feb097cf0 100644 --- a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp @@ -218,6 +218,10 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { EXPECT_TRUE(fs::exists(targetConfig)); EXPECT_THAT(readFile(targetConfig), ::testing::HasSubstr("First commit")); + // Verify metadata file was created alongside the config revision + fs::path targetMetadata = cliConfigDir / "agent-r2.metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); + // Verify symlink was replaced and points to new revision EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); @@ -249,17 +253,57 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { EXPECT_TRUE(fs::exists(targetConfig)); EXPECT_THAT(readFile(targetConfig), ::testing::HasSubstr("Second commit")); + // Verify metadata file was created alongside the config revision + fs::path targetMetadata = cliConfigDir / "agent-r3.metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); + // Verify symlink was updated to point to r3 EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); - // Verify all revisions exist + // Verify all revisions and their metadata files 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")); + EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.metadata.json")); + EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.metadata.json")); } } +// 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 sessionConfig = sessionDir / "agent.conf"; + fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; + + // Setup mock agent server + setupMockedAgentServer(); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); + + // Create a new session and immediately commit it + // This tests that metadata file is created during session initialization + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + cliConfigDir.string()); + + // Verify metadata file was created during session initialization + fs::path metadataPath = sessionDir / "conf_metadata.json"; + EXPECT_TRUE(fs::exists(metadataPath)); + + // Make no changes to the session. It's initialized but that's it. + + // Commit should succeed, right now empty sessions still commmit a new + // revision (TODO: fix this so we don't create empty commits). + auto result = session.commit(localhost()); + EXPECT_EQ(result.revision, 2); + + // Verify metadata file was copied to revision directory + fs::path targetMetadata = cliConfigDir / "agent-r2.metadata.json"; + EXPECT_TRUE(fs::exists(targetMetadata)); +} + TEST_F(ConfigSessionTestFixture, multipleChangesInOneSession) { fs::path sessionDir = testHomeDir_ / ".fboss2"; fs::path sessionConfig = sessionDir / "agent.conf"; @@ -441,8 +485,9 @@ TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; // Setup mock agent server + // Either 1 or 2 commits might succeed depending on the race setupMockedAgentServer(); - EXPECT_CALL(getMockAgent(), reloadConfig()).Times(2); + EXPECT_CALL(getMockAgent(), reloadConfig()).Times(testing::Between(1, 2)); // Test concurrent session creation and commits for the SAME user // This tests the race conditions in: @@ -452,14 +497,18 @@ TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { // 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. + // The atomic operations ensure no crashes or corruption. However, if one + // thread commits and deletes the session files before the other thread + // calls commit(), the second thread will get "No config session exists". + // This is a valid race outcome - the important thing is no crashes. std::atomic revision1{0}; std::atomic revision2{0}; + std::atomic thread1NoSession{false}; + std::atomic thread2NoSession{false}; - auto commitTask = [&](const std::string& description, std::atomic& rev) { + auto commitTask = [&](const std::string& description, + std::atomic& rev, + std::atomic& noSession) { // Both threads use the SAME session path fs::path sessionDir = testHomeDir_ / ".fboss2_shared"; fs::path sessionConfig = sessionDir / "agent.conf"; @@ -475,28 +524,58 @@ TEST_F(ConfigSessionTestFixture, concurrentSessionCreationSameUser) { ports[0].description() = description; session.saveConfig(); - rev = session.commit(localhost()).revision; + try { + rev = session.commit(localhost()).revision; + } catch (const std::runtime_error& e) { + // If the other thread already committed and deleted the session files, + // we'll get "No config session exists" - this is a valid race outcome + if (folly::StringPiece(e.what()).contains("No config session exists")) { + noSession = true; + } else { + throw; // Re-throw unexpected errors + } + } }; - std::thread thread1(commitTask, "First commit", std::ref(revision1)); - std::thread thread2(commitTask, "Second commit", std::ref(revision2)); + std::thread thread1( + commitTask, + "First commit", + std::ref(revision1), + std::ref(thread1NoSession)); + std::thread thread2( + commitTask, + "Second commit", + std::ref(revision2), + std::ref(thread2NoSession)); 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")); + // At least one commit should succeed + bool commit1Succeeded = revision1.load() != 0; + bool commit2Succeeded = revision2.load() != 0; + EXPECT_TRUE(commit1Succeeded || commit2Succeeded); + + // If both succeeded, they should have different revision numbers + if (commit1Succeeded && commit2Succeeded) { + 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")); + } else { + // One thread got "No config session exists" because the other committed + // first + EXPECT_TRUE(thread1NoSession.load() || thread2NoSession.load()); + // The successful commit should be r2 + int successfulRevision = + commit1Succeeded ? revision1.load() : revision2.load(); + EXPECT_EQ(successfulRevision, 2); + EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); + } // The history command would list all three revisions with their metadata } @@ -725,16 +804,16 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsToMetadataFile) { fs::path sessionConfig = sessionDir / "agent.conf"; fs::path metadataFile = sessionDir / "conf_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()); - // Set to AGENT_RESTART - session.updateRequiredAction( - cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + // Load the config (required before saveConfig) + session.getAgentConfig(); + session.saveConfig(cli::ConfigActionLevel::AGENT_RESTART); } // Verify metadata file exists and has correct JSON format @@ -782,15 +861,16 @@ 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()); - session1.updateRequiredAction( - cli::ConfigActionLevel::AGENT_RESTART, cli::AgentType::WEDGE_AGENT); + // Load the config (required before saveConfig) + session1.getAgentConfig(); + session1.saveConfig(cli::ConfigActionLevel::AGENT_RESTART); } // Second session: verify action level was persisted @@ -806,4 +886,180 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { } } +TEST_F(ConfigSessionTestFixture, commandTrackingBasic) { + fs::path sessionDir = testHomeDir_ / ".fboss2"; + fs::path sessionConfig = sessionDir / "agent.conf"; + fs::path metadataFile = sessionDir / "conf_metadata.json"; + + // Create a ConfigSession, execute command, and verify persistence + { + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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"; + fs::path sessionConfig = sessionDir / "agent.conf"; + + // Create a ConfigSession + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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"; + fs::path sessionConfig = sessionDir / "agent.conf"; + + // First session: execute some commands + { + TestableConfigSession session1( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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"; + fs::path sessionConfig = sessionDir / "agent.conf"; + + // Create a ConfigSession and add some commands + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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 / "conf_metadata.json"; + + // 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(systemConfigPath_, sessionConfig); + + // Create a ConfigSession - should load commands from metadata file + TestableConfigSession session( + sessionConfig.string(), + systemConfigPath_.string(), + (testEtcDir_ / "coop" / "cli").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]); +} + } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/TestableConfigSession.h b/fboss/cli/fboss2/test/TestableConfigSession.h index 1f2179dcf6c10..5141ee0560716 100644 --- a/fboss/cli/fboss2/test/TestableConfigSession.h +++ b/fboss/cli/fboss2/test/TestableConfigSession.h @@ -26,6 +26,9 @@ class TestableConfigSession : public ConfigSession { // Expose protected setInstance() for testing using ConfigSession::setInstance; + + // Expose protected addCommand() for testing + using ConfigSession::addCommand; }; } // namespace facebook::fboss From af5398aa190b468589f1acda3faa7a5b229e8b9a Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Mon, 19 Jan 2026 20:50:20 +0000 Subject: [PATCH 05/18] Replace file-based config versioning with Git Replace the basic file-based configuration versioning mechanism with Git-based versioning for the CLI config session. Key changes: - Add new Git class (Git.h/cpp) providing a simple interface for Git operations: init, commit, log, show, resolveRef, getHead, hasCommits - Use folly::Subprocess with full path /usr/bin/git for all Git commands - Replace revision files (agent-rN.conf + symlink) with atomic writes to agent.conf tracked in a local Git repository - Use Git commit SHAs as revision identifiers instead of rN format - Update RevisionList validation to accept Git SHAs (7+ hex chars) Repository initialization: - Automatically initialize Git repo if it doesn't exist - Automatically create initial commit if repo has no commits but config file exists - Use --shared=group flag and umask 0002 to ensure .git directory is group-writable when /etc/coop is group-writable Commands updated: - config history: Shows Git commit log with SHA, author, timestamp, message - config session diff: Uses git show to compare commits - config session commit: Creates Git commits with username as author - config rollback: Reads config from Git history and creates new commit Test updates: - Update all CLI config tests to use Git-based setup - Initialize Git repo and create initial commit in test fixtures --- cmake/CliFboss2.cmake | 2 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 2 + .../config/history/CmdConfigHistory.cpp | 131 +--- .../config/rollback/CmdConfigRollback.cpp | 20 +- .../config/session/CmdConfigSessionCommit.cpp | 9 +- .../config/session/CmdConfigSessionDiff.cpp | 114 +-- fboss/cli/fboss2/session/ConfigSession.cpp | 463 +++++------- fboss/cli/fboss2/session/ConfigSession.h | 94 +-- fboss/cli/fboss2/session/Git.cpp | 312 ++++++++ fboss/cli/fboss2/session/Git.h | 146 ++++ fboss/cli/fboss2/test/BUCK | 1 + .../cli/fboss2/test/CmdConfigHistoryTest.cpp | 230 +++--- .../CmdConfigInterfaceDescriptionTest.cpp | 32 +- ...onfigInterfaceSwitchportAccessVlanTest.cpp | 61 +- .../test/CmdConfigQosBufferPoolTest.cpp | 55 +- .../fboss2/test/CmdConfigSessionDiffTest.cpp | 151 ++-- .../cli/fboss2/test/CmdConfigSessionTest.cpp | 681 +++++++----------- fboss/cli/fboss2/test/GitTest.cpp | 125 ++++ fboss/cli/fboss2/test/TestableConfigSession.h | 11 +- fboss/cli/fboss2/utils/CmdUtils.cpp | 43 +- 21 files changed, 1531 insertions(+), 1153 deletions(-) create mode 100644 fboss/cli/fboss2/session/Git.cpp create mode 100644 fboss/cli/fboss2/session/Git.h create mode 100644 fboss/cli/fboss2/test/GitTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 1c50cf34d25d7..c578ef959bc1b 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -612,6 +612,8 @@ add_library(fboss2_config_lib fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp fboss/cli/fboss2/session/ConfigSession.h fboss/cli/fboss2/session/ConfigSession.cpp + fboss/cli/fboss2/session/Git.h + fboss/cli/fboss2/session/Git.cpp fboss/cli/fboss2/utils/InterfaceList.h fboss/cli/fboss2/utils/InterfaceList.cpp fboss/cli/fboss2/CmdListConfig.cpp diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index f1d9dbcce2274..9940f3572ba7d 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -77,6 +77,7 @@ add_executable(fboss2_cmd_test # 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 ) diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index b5672da637526..f40415601a63e 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -807,6 +807,7 @@ cpp_library( "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", "session/ConfigSession.cpp", + "session/Git.cpp", "utils/InterfaceList.cpp", ], headers = [ @@ -825,6 +826,7 @@ cpp_library( "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", "session/ConfigSession.h", + "session/Git.h", "utils/InterfaceList.h", ], exported_deps = [ diff --git a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp index e6a950462f6d0..2ad6bf9f5a9fb 100644 --- a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp +++ b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp @@ -9,120 +9,23 @@ */ #include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" -#include -#include -#include -#include #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 +33,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 diff --git a/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp b/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp index 2b3b04e07a0ff..595ae26f54415 100644 --- a/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp +++ b/fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp @@ -21,28 +21,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( diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp index 9c40594da2320..81c021273f523 100644 --- a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.cpp @@ -26,14 +26,15 @@ CmdConfigSessionCommitTraits::RetType CmdConfigSessionCommit::queryClient( auto result = session.commit(hostInfo); std::string message; + std::string shortSha = result.commitSha.substr(0, 7); if (result.actionLevel == cli::ConfigActionLevel::AGENT_RESTART) { message = fmt::format( - "Config session committed successfully as r{} and wedge_agent restarted.", - result.revision); + "Config session committed successfully as {} and wedge_agent restarted.", + shortSha); } else { message = fmt::format( - "Config session committed successfully as r{} and config reloaded.", - result.revision); + "Config session committed successfully as {} and config reloaded.", + shortSha); } return message; diff --git a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp index efb0fb447f8aa..8efe2a5244735 100644 --- a/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.cpp @@ -11,44 +11,62 @@ #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" #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; - } + ConfigSession& session) { + auto& git = session.getGit(); + std::string cliConfigPath = session.getCliConfigPath(); - // Build the path (revision is already validated to be in "rN" format) - std::string revisionPath = cliConfigDir + "/agent-" + revision + ".conf"; - - // 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 +75,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 +111,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 +118,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 +143,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); - - std::string label1 = - revisions[0] == "current" ? "current live config" : revisions[0]; - std::string label2 = - revisions[1] == "current" ? "current live config" : revisions[1]; + auto [content1, label1] = getRevisionContent(revisions[0], session); + auto [content2, label2] = getRevisionContent(revisions[1], session); - return executeDiff(path1, path2, label1, label2); + return executeDiff(content1, content2, label1, label2); } // More than 2 arguments is an error diff --git a/fboss/cli/fboss2/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index 45fccb8b571d9..6fd9c4e1ed222 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -10,32 +10,24 @@ #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 "fboss/agent/AgentDirectoryUtil.h" #include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" -#include "fboss/cli/fboss2/utils/CmdClientUtils.h" +#include "fboss/cli/fboss2/utils/CmdClientUtils.h" // NOLINT(misc-include-cleaner) #include "fboss/cli/fboss2/utils/PortMap.h" namespace fs = std::filesystem; @@ -61,8 +53,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 @@ -89,53 +82,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,25 +119,6 @@ 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. - */ -int getCurrentRevisionNumber(const std::string& systemConfigPath) { - std::error_code ec; - - if (!fs::is_symlink(systemConfigPath, ec)) { - return -1; - } - - std::string target = fs::read_symlink(systemConfigPath, ec); - if (ec) { - return -1; - } - - return ConfigSession::extractRevisionNumber(target); -} - /* * Read the command line from /proc/self/cmdline, skipping argv[0]. * Returns the command arguments as a space-separated string, @@ -223,31 +150,28 @@ std::string readCommandLineFromProc() { } // 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(); } @@ -271,19 +195,23 @@ void ConfigSession::setInstance(std::unique_ptr newInstance) { } 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() { @@ -339,7 +267,7 @@ 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); // Automatically record the command from /proc/self/cmdline. // This ensures all config commands are tracked without requiring manual @@ -348,10 +276,13 @@ void ConfigSession::saveConfig( CHECK(!rawCmd.empty()) << "saveConfig() called with no command line arguments"; // Only record if this is a config command and not already the last one - // recorded as that'd be idempotent anyway. - if (rawCmd.find("config ") == 0 && - (commands_.empty() || commands_.back() != rawCmd)) { - commands_.push_back(rawCmd); + // recorded as that'd be idempotent anyway. Strip any leading flags. + auto pos = rawCmd.find("config "); + if (pos != std::string::npos) { + std::string cmd = rawCmd.substr(pos); + if (commands_.empty() || commands_.back() != cmd) { + commands_.push_back(cmd); + } } // If an action level was provided, update the required action level @@ -363,29 +294,22 @@ void ConfigSession::saveConfig( saveMetadata(); } -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::AgentType agent) { @@ -548,9 +472,10 @@ void ConfigSession::restartAgent(cli::AgentType agent) { void ConfigSession::loadConfig() { 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( @@ -566,10 +491,10 @@ 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()); + // Ensure the session config directory exists + ensureDirectoryExists(sessionConfigDir_); copySystemConfigToSession(); // Create initial empty metadata file for new sessions saveMetadata(); @@ -579,40 +504,37 @@ void ConfigSession::initializeSession() { } } -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() { + // 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) { @@ -621,56 +543,46 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { "No config session exists. Make a config change first."); } - ensureDirectoryExists(cliConfigDir_); + std::string cliConfigDir = getCliConfigDir(); + std::string cliConfigPath = getCliConfigPath(); + std::string sessionConfigPath = getSessionConfigPath(); + std::string systemConfigPath = getSystemConfigPath(); - // 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; + ensureDirectoryExists(cliConfigDir); - // Read the old symlink target for rollback if needed - std::string oldSymlinkTarget; - if (!fs::is_symlink(systemConfigPath_)) { - throw std::runtime_error( - fmt::format( - "{} is not a symlink. Expected it to be a symlink.", - systemConfigPath_)); - } - oldSymlinkTarget = fs::read_symlink(systemConfigPath_, ec); - if (ec) { + // 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)); } - // Copy session config to the atomically-created revision file - // Overwrite the empty file that was created by createNextRevisionFile - fs::copy_file( - sessionConfigPath_, - targetConfigPath, - fs::copy_options::overwrite_existing, - ec); - if (ec) { - // Clean up the revision file we created - fs::remove(targetConfigPath); - throw std::runtime_error( - fmt::format( - "Failed to copy session config to {}: {}", - targetConfigPath, - ec.message())); + // 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 the metadata file alongside the config revision - // e.g., agent-r123.conf -> agent-r123.metadata.json // This is required for rollback functionality std::string metadataPath = getMetadataPath(); std::string targetMetadataPath = - fmt::format("{}/agent-r{}.metadata.json", cliConfigDir_, revision); - fs::copy_file(metadataPath, targetMetadataPath, ec); + fmt::format("{}/cli_metadata.json", cliConfigDir); + std::error_code ec; + fs::copy_file( + 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 metadata to {}: {}", @@ -678,32 +590,51 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { 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"); // Check the required action level for this commit auto actionLevel = getRequiredAction(cli::AgentType::WEDGE_AGENT); // Apply the config based on the required action level + std::string commitSha; try { if (actionLevel == cli::ConfigActionLevel::AGENT_RESTART) { // For AGENT_RESTART changes, restart the agent via systemd // This will cause the agent to pick up the new config on startup restartAgent(cli::AgentType::WEDGE_AGENT); - LOG(INFO) << "Config committed as revision r" << revision - << " (agent restarted)"; } else { // For HITLESS changes, use reloadConfig() which applies without restart auto client = utils::createClient< apache::thrift::Client>(hostInfo); client->sync_reloadConfig(); - LOG(INFO) << "Config committed as revision r" << revision - << " (config reloaded)"; } + + // Create a Git commit with all changed files: + // - cli/agent.conf (the config file) + // - cli/cli_metadata.json (the metadata file) + // - agent.conf (the symlink, in case it was updated) + std::string commitMessage = fmt::format("Config commit by {}", username_); + commitSha = git_->commit( + {cliConfigPath, targetMetadataPath, systemConfigPath}, + commitMessage, + username_, + ""); + LOG(INFO) << "Config committed as " << commitSha.substr(0, 8) + << (actionLevel == cli::ConfigActionLevel::AGENT_RESTART + ? " (agent restarted)" + : " (config reloaded)"); } catch (const std::exception& ex) { - // Rollback: atomically restore the old symlink + // Rollback: restore the old config try { - atomicSymlinkUpdate(systemConfigPath_, oldSymlinkTarget); + if (!oldConfigData.empty()) { + folly::writeFileAtomic( + cliConfigPath, oldConfigData, 0644, folly::SyncType::WITH_SYNC); + } // If this was an AGENT_RESTART change, we need to restart the agent again // so it picks up the old config (in case the restart was partially // successful before failing) @@ -725,123 +656,125 @@ 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()); } // Reset action level after successful commit resetRequiredAction(cli::AgentType::WEDGE_AGENT); - return CommitResult{revision, actionLevel}; + return CommitResult{commitSha, actionLevel}; } -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) { +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( - "Cannot rollback: already at the first revision (r1)"); + "Cannot rollback: no previous revision available in Git history"); } - // Rollback to the previous revision - std::string targetRevision = "r" + std::to_string(currentRevision - 1); - return rollback(hostInfo, targetRevision); + // Rollback to the previous commit (second in the list) + return rollback(hostInfo, commits[1].sha1); } -int ConfigSession::rollback( +std::string 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); - - // Check if the target revision exists - if (!fs::exists(targetConfigPath)) { - throw std::runtime_error( - fmt::format( - "Revision {} does not exist at {}", revision, targetConfigPath)); - } - - std::error_code ec; - - // Verify that the system config is a symlink - if (!fs::is_symlink(systemConfigPath_)) { - throw std::runtime_error( - fmt::format( - "{} is not a symlink. Expected it to be a symlink.", - systemConfigPath_)); + 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)); + } } - - // Read the old symlink target in case we need to undo the rollback - std::string oldSymlinkTarget = fs::read_symlink(systemConfigPath_, ec); - if (ec) { - throw std::runtime_error( - fmt::format( - "Failed to read symlink {}: {}", systemConfigPath_, ec.message())); + 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)); + } } - // First, create a new revision with the same content as the target revision - auto [newRevisionPath, newRevision] = - createNextRevisionFile(fmt::format("{}/agent", cliConfigDir_)); - - // 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); - throw std::runtime_error( - fmt::format( - "Failed to create new revision for rollback: {}", ec.message())); - } + // 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); - // Atomically update the symlink to point to the new revision - atomicSymlinkUpdate(systemConfigPath_, newRevisionPath); + // Ensure the system config symlink points to the CLI config + atomicSymlinkUpdate(systemConfigPath, "cli/agent.conf"); - // Reload the config - if this fails, atomically undo the rollback + // 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 32e14c83cb2ac..73f3a1ffa2a1b 100644 --- a/fboss/cli/fboss2/session/ConfigSession.h +++ b/fboss/cli/fboss2/session/ConfigSession.h @@ -14,8 +14,8 @@ #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 +29,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 +51,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: @@ -93,32 +96,35 @@ class ConfigSession { // 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 + std::string commitSha; // The git commit SHA of the committed config cli::ConfigActionLevel actionLevel; // The action level that was required // Note: configReloaded can be inferred from actionLevel: // - HITLESS: config was reloaded via reloadConfig() // - AGENT_RESTART: agent was restarted via systemd }; - // 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); + // 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; @@ -139,9 +145,10 @@ class ConfigSession { std::optional actionLevel = std::nullopt, cli::AgentType agent = cli::AgentType::WEDGE_AGENT); - // 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. @@ -166,10 +173,7 @@ class ConfigSession { 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); @@ -179,11 +183,13 @@ class ConfigSession { 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_; @@ -197,7 +203,10 @@ class ConfigSession { // List of commands executed in this session, persisted to disk std::vector commands_; - // Path to the metadata file (e.g., ~/.fboss2/metadata) + // 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 metadata (action levels and commands) from disk @@ -214,6 +223,9 @@ class ConfigSession { void initializeSession(); void copySystemConfigToSession(); 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 f2ec51dd9d63e..9139c24c8eee9 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -105,6 +105,7 @@ cpp_unittest( "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 index 777034cdb028d..bcf9469da10e1 100644 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp @@ -13,6 +13,7 @@ #include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.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/InterfaceList.h" @@ -45,18 +46,21 @@ class CmdConfigInterfaceDescriptionTestFixture : public CmdHandlerTestBase { } // 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_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); + systemConfigDir_ = testEtcDir_ / "coop"; + 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 as agent-r1.conf in the cli directory - 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": [ { @@ -77,17 +81,19 @@ class CmdConfigInterfaceDescriptionTestFixture : public CmdHandlerTestBase { } })"); - // Create symlink at agent.conf pointing to agent-r1.conf - 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"); // Initialize the ConfigSession singleton for all tests - fs::path sessionConfig = testHomeDir_ / ".fboss2" / "agent.conf"; + fs::path sessionDir = testHomeDir_ / ".fboss2"; TestableConfigSession::setInstance( std::make_unique( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string())); + sessionDir.string(), systemConfigDir_.string())); } void TearDown() override { @@ -112,7 +118,7 @@ class CmdConfigInterfaceDescriptionTestFixture : public CmdHandlerTestBase { fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; + fs::path systemConfigDir_; }; // Test setting description on a single existing interface diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp index 5a426a939b473..55adf897b19b5 100644 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp @@ -8,17 +8,18 @@ * */ -#include +#include #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/PortMap.h" +#include "fboss/cli/fboss2/utils/PortMap.h" // NOLINT(misc-include-cleaner) namespace fs = std::filesystem; @@ -49,16 +50,22 @@ class CmdConfigInterfaceSwitchportAccessVlanTestFixture // Create test directories fs::create_directories(testHomeDir_); - fs::create_directories(testEtcDir_ / "coop"); - fs::create_directories(testEtcDir_ / "coop" / "cli"); + // 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); - // Create a test system config file with ports - fs::path initialRevision = testEtcDir_ / "coop" / "cli" / "agent-r1.conf"; - createTestConfig(initialRevision, R"({ + // 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": [ { @@ -79,16 +86,16 @@ class CmdConfigInterfaceSwitchportAccessVlanTestFixture } })"); - // 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"; + // 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); @@ -108,9 +115,8 @@ class CmdConfigInterfaceSwitchportAccessVlanTestFixture fs::path testHomeDir_; fs::path testEtcDir_; - fs::path systemConfigPath_; - fs::path sessionConfigPath_; - fs::path cliConfigDir_; + fs::path systemConfigDir_; + fs::path sessionConfigDir_; }; // Tests for VlanIdValue validation @@ -194,15 +200,15 @@ TEST_F( TEST_F( CmdConfigInterfaceSwitchportAccessVlanTestFixture, queryClientSetsIngressVlanMultiplePorts) { - TestableConfigSession session( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string()); + fs::create_directories(sessionConfigDir_); + + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); VlanIdValue vlanId({"2001"}); - // Create InterfaceList from port names utils::InterfaceList interfaces({"eth1/1/1", "eth1/2/1"}); auto result = cmd.queryClient(localhost(), interfaces, vlanId); @@ -213,6 +219,7 @@ TEST_F( 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(); @@ -226,15 +233,15 @@ TEST_F( TEST_F( CmdConfigInterfaceSwitchportAccessVlanTestFixture, queryClientThrowsOnEmptyInterfaceList) { - TestableConfigSession session( - sessionConfigPath_.string(), - systemConfigPath_.string(), - cliConfigDir_.string()); + fs::create_directories(sessionConfigDir_); + + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigDir_.string(), systemConfigDir_.string())); auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); VlanIdValue vlanId({"100"}); - // Empty InterfaceList is valid to construct but queryClient should throw utils::InterfaceList emptyInterfaces({}); EXPECT_THROW( cmd.queryClient(localhost(), emptyInterfaces, vlanId), 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/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 0f02feb097cf0..e8977c8d88845 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": [ { @@ -73,10 +80,13 @@ class ConfigSessionTestFixture : public CmdHandlerTestBase { } })"); - // 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 +127,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 +154,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 +168,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 +176,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(); @@ -212,29 +206,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 metadata file was created alongside the config revision - fs::path targetMetadata = cliConfigDir / "agent-r2.metadata.json"; + fs::path targetMetadata = systemConfigDir_ / "cli" / "cli_metadata.json"; EXPECT_TRUE(fs::exists(targetMetadata)); - // Verify symlink was replaced and points to new revision - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); + // 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()); @@ -247,26 +240,26 @@ 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 = cliConfigDir / "agent-r3.metadata.json"; + fs::path targetMetadata = systemConfigDir_ / "cli" / "cli_metadata.json"; EXPECT_TRUE(fs::exists(targetMetadata)); - // Verify symlink was updated to point to r3 - EXPECT_TRUE(fs::is_symlink(systemConfigPath_)); - EXPECT_EQ(fs::read_symlink(systemConfigPath_), targetConfig); + // Verify system config was updated + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("Second commit")); - // Verify all revisions and their metadata files 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")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.metadata.json")); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r3.metadata.json")); + // 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 metadata file was also committed to git + auto metadataCommits = git.log(targetMetadata.string()); + EXPECT_EQ(metadataCommits.size(), 2); // 2 commits } } @@ -274,33 +267,33 @@ TEST_F(ConfigSessionTestFixture, sessionCommit) { // This verifies that initializeSession() creates the metadata file TEST_F(ConfigSessionTestFixture, commitOnNewlyInitializedSession) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; - fs::path cliConfigDir = testEtcDir_ / "coop" / "cli"; + fs::path cliConfigDir = systemConfigDir_ / "cli"; // Setup mock agent server setupMockedAgentServer(); EXPECT_CALL(getMockAgent(), reloadConfig()).Times(1); - // Create a new session and immediately commit it + // Create a new session // This tests that metadata file is created during session initialization - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - cliConfigDir.string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Verify metadata file was created during session initialization - fs::path metadataPath = sessionDir / "conf_metadata.json"; + fs::path metadataPath = sessionDir / "cli_metadata.json"; EXPECT_TRUE(fs::exists(metadataPath)); - // Make no changes to the session. It's initialized but that's it. + // 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, right now empty sessions still commmit a new - // revision (TODO: fix this so we don't create empty commits). + // Commit should succeed auto result = session.commit(localhost()); - EXPECT_EQ(result.revision, 2); + EXPECT_FALSE(result.commitSha.empty()); - // Verify metadata file was copied to revision directory - fs::path targetMetadata = cliConfigDir / "agent-r2.metadata.json"; + // Verify metadata file was copied to CLI config directory + fs::path targetMetadata = cliConfigDir / "cli_metadata.json"; EXPECT_TRUE(fs::exists(targetMetadata)); } @@ -309,11 +302,7 @@ TEST_F(ConfigSessionTestFixture, multipleChangesInOneSession) { 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(); @@ -339,12 +328,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(); @@ -362,9 +348,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(); @@ -374,15 +358,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 setupMockedAgentServer(); @@ -390,11 +372,7 @@ TEST_F(ConfigSessionTestFixture, symlinkRollbackOnFailure) { .WillOnce(::testing::Throw(std::runtime_error("Reload failed"))); // 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(); @@ -402,329 +380,212 @@ TEST_F(ConfigSessionTestFixture, symlinkRollbackOnFailure) { ports[0].description() = "Failed change"; session.saveConfig(); - // 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(); ASSERT_FALSE(ports.empty()); - ports[0].description() = description; + ports[0].description() = "First commit"; session.saveConfig(); - 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"; + auto result = session.commit(localhost()); + commitSha1 = result.commitSha; + } - // Setup mock agent server - // Either 1 or 2 commits might succeed depending on the race - setupMockedAgentServer(); - EXPECT_CALL(getMockAgent(), reloadConfig()).Times(testing::Between(1, 2)); - - // 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. However, if one - // thread commits and deletes the session files before the other thread - // calls commit(), the second thread will get "No config session exists". - // This is a valid race outcome - the important thing is no crashes. - std::atomic revision1{0}; - std::atomic revision2{0}; - std::atomic thread1NoSession{false}; - std::atomic thread2NoSession{false}; - - auto commitTask = [&](const std::string& description, - std::atomic& rev, - std::atomic& noSession) { - // 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(); ASSERT_FALSE(ports.empty()); - ports[0].description() = description; + ports[0].description() = "Second commit"; session.saveConfig(); - try { - rev = session.commit(localhost()).revision; - } catch (const std::runtime_error& e) { - // If the other thread already committed and deleted the session files, - // we'll get "No config session exists" - this is a valid race outcome - if (folly::StringPiece(e.what()).contains("No config session exists")) { - noSession = true; - } else { - throw; // Re-throw unexpected errors - } - } - }; - - std::thread thread1( - commitTask, - "First commit", - std::ref(revision1), - std::ref(thread1NoSession)); - std::thread thread2( - commitTask, - "Second commit", - std::ref(revision2), - std::ref(thread2NoSession)); - - thread1.join(); - thread2.join(); - - // At least one commit should succeed - bool commit1Succeeded = revision1.load() != 0; - bool commit2Succeeded = revision2.load() != 0; - EXPECT_TRUE(commit1Succeeded || commit2Succeeded); - - // If both succeeded, they should have different revision numbers - if (commit1Succeeded && commit2Succeeded) { - 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")); - } else { - // One thread got "No config session exists" because the other committed - // first - EXPECT_TRUE(thread1NoSession.load() || thread2NoSession.load()); - // The successful commit should be r2 - int successfulRevision = - commit1Succeeded ? revision1.load() : revision2.load(); - EXPECT_EQ(successfulRevision, 2); - EXPECT_TRUE(fs::exists(cliConfigDir / "agent-r2.conf")); + auto result = session.commit(localhost()); + commitSha2 = result.commitSha; } - // The history command would list all three revisions with their metadata + // Both commits should succeed with different commit SHAs + EXPECT_FALSE(commitSha1.empty()); + EXPECT_FALSE(commitSha2.empty()); + EXPECT_NE(commitSha1, commitSha2); + + // 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()); + + auto& config1 = session.getAgentConfig(); + (*config1.sw()->ports())[0].description() = "First version"; + session.saveConfig(); + session.commit(localhost()); + } + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); + + auto& config2 = session.getAgentConfig(); + (*config2.sw()->ports())[0].description() = "Second version"; + session.saveConfig(); + session.commit(localhost()); + } - // Create a testable ConfigSession with test paths - TestableConfigSession session( - sessionConfigPath.string(), symlinkPath.string(), cliConfigDir.string()); + // Verify current content is "Second version" + EXPECT_THAT(readFile(cliConfigPath), ::testing::HasSubstr("Second version")); - // Call the actual rollback method without a revision (should go to previous) - int newRevision = session.rollback(localhost()); + // Rollback to previous commit (no argument) + { + TestableConfigSession session( + sessionDir.string(), systemConfigDir_.string()); - // 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")); + std::string rollbackSha = session.rollback(localhost()); - // Verify r4 has same content as r2 (the previous revision) - EXPECT_EQ( - readFile(cliConfigDir / "agent-r2.conf"), - readFile(cliConfigDir / "agent-r4.conf")); + // Verify rollback succeeded + EXPECT_FALSE(rollbackSha.empty()); - // 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 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( @@ -734,13 +595,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_RESTART session.updateRequiredAction( @@ -754,13 +611,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_RESTART first session.updateRequiredAction( @@ -778,13 +631,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_RESTART session.updateRequiredAction( @@ -801,15 +650,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 via saveConfig { TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); // Load the config (required before saveConfig) session.getAgentConfig(); @@ -832,7 +678,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); @@ -843,13 +690,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( @@ -859,14 +703,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 via saveConfig { TestableConfigSession session1( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); // Load the config (required before saveConfig) session1.getAgentConfig(); @@ -876,9 +717,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::AgentType::WEDGE_AGENT), @@ -888,15 +727,12 @@ TEST_F(ConfigSessionTestFixture, actionLevelPersistsAcrossSessions) { TEST_F(ConfigSessionTestFixture, commandTrackingBasic) { 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, execute command, and verify persistence { TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); // Initially, no commands should be recorded EXPECT_TRUE(session.getCommands().empty()); @@ -933,13 +769,9 @@ TEST_F(ConfigSessionTestFixture, commandTrackingBasic) { TEST_F(ConfigSessionTestFixture, commandTrackingMultipleCommands) { 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()); // Execute multiple commands auto& config = session.getAgentConfig(); @@ -968,14 +800,11 @@ TEST_F(ConfigSessionTestFixture, commandTrackingMultipleCommands) { TEST_F(ConfigSessionTestFixture, commandTrackingPersistsAcrossSessions) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // First session: execute some commands { TestableConfigSession session1( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); auto& config = session1.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -993,9 +822,7 @@ TEST_F(ConfigSessionTestFixture, commandTrackingPersistsAcrossSessions) { // Second session: verify commands were persisted { TestableConfigSession session2( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + sessionDir.string(), systemConfigDir_.string()); EXPECT_EQ(2, session2.getCommands().size()); EXPECT_EQ("config interface eth1/1/1 mtu 9000", session2.getCommands()[0]); @@ -1007,13 +834,9 @@ TEST_F(ConfigSessionTestFixture, commandTrackingPersistsAcrossSessions) { TEST_F(ConfigSessionTestFixture, commandTrackingClearedOnReset) { fs::path sessionDir = testHomeDir_ / ".fboss2"; - fs::path sessionConfig = sessionDir / "agent.conf"; // Create a ConfigSession and add some commands - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); auto& config = session.getAgentConfig(); auto& ports = *config.sw()->ports(); @@ -1035,7 +858,8 @@ TEST_F(ConfigSessionTestFixture, commandTrackingClearedOnReset) { TEST_F(ConfigSessionTestFixture, commandTrackingLoadsFromMetadataFile) { 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); @@ -1047,13 +871,10 @@ TEST_F(ConfigSessionTestFixture, commandTrackingLoadsFromMetadataFile) { metaFile.close(); // Also create the session config file - fs::copy_file(systemConfigPath_, sessionConfig); + fs::copy_file(cliConfigPath, sessionConfig); // Create a ConfigSession - should load commands from metadata file - TestableConfigSession session( - sessionConfig.string(), - systemConfigPath_.string(), - (testEtcDir_ / "coop" / "cli").string()); + TestableConfigSession session(sessionDir.string(), systemConfigDir_.string()); // Verify commands were loaded EXPECT_EQ(3, session.getCommands().size()); 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 5141ee0560716..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,10 +22,10 @@ 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; diff --git a/fboss/cli/fboss2/utils/CmdUtils.cpp b/fboss/cli/fboss2/utils/CmdUtils.cpp index 1c14fb6b95bd3..c391f4f8c2cbb 100644 --- a/fboss/cli/fboss2/utils/CmdUtils.cpp +++ b/fboss/cli/fboss2/utils/CmdUtils.cpp @@ -9,13 +9,10 @@ */ #include "fboss/cli/fboss2/utils/CmdUtils.h" #include -#include +#include // NOLINT(misc-include-cleaner) #include "folly/Conv.h" -#include - #include -#include #include using namespace std::chrono; @@ -502,31 +499,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'"); } } From e91d0a05010753c39c3fc9fa0877b32046cb290d Mon Sep 17 00:00:00 2001 From: benoit-nexthop Date: Fri, 23 Jan 2026 10:58:16 +0100 Subject: [PATCH 06/18] Add `config session rebase` command for concurrent session conflict resolution When multiple users have concurrent config sessions, the first user to commit succeeds, but subsequent users get an error because their session is based on a stale commit. Previously, whoever committed last would overwrite changes of other users that committed before them. This commit adds a `config session rebase` command that: 1. Takes the diff between the config as of the base commit and the session 2. Re-applies this diff on top of the current HEAD (similar to git rebase) 3. Uses a recursive 3-way merge algorithm for JSON objects 4. Detects and reports conflicts at specific JSON paths 5. Updates the session's base to the current HEAD on success The 3-way merge algorithm handles: - Objects: recursively merges each key - Arrays: element-by-element merge if sizes match - Scalars: conflict if both head and session changed differently from base Add unit tests covering: - Successful rebase with non-conflicting changes - Rebase failure when changes conflict - Rebase not needed when session is already up-to-date NOS-3962 #done --- cmake/CliFboss2.cmake | 2 + fboss/cli/fboss2/BUCK | 2 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 5 + fboss/cli/fboss2/CmdListConfig.cpp | 7 + fboss/cli/fboss2/cli_metadata.thrift | 3 + .../config/session/CmdConfigSessionRebase.cpp | 30 ++ .../config/session/CmdConfigSessionRebase.h | 39 +++ fboss/cli/fboss2/session/ConfigSession.cpp | 222 ++++++++++++++- fboss/cli/fboss2/session/ConfigSession.h | 15 +- .../cli/fboss2/test/CmdConfigSessionTest.cpp | 262 ++++++++++++++++++ .../test_config_concurrent_sessions.py | 155 +++++++++++ 11 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp create mode 100644 fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h create mode 100644 fboss/oss/cli_tests/test_config_concurrent_sessions.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index c578ef959bc1b..d2937ed7a3f23 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -610,6 +610,8 @@ add_library(fboss2_config_lib 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/session/ConfigSession.h fboss/cli/fboss2/session/ConfigSession.cpp fboss/cli/fboss2/session/Git.h diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index f40415601a63e..4007bff2e092e 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -806,6 +806,7 @@ cpp_library( "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", + "commands/config/session/CmdConfigSessionRebase.cpp", "session/ConfigSession.cpp", "session/Git.cpp", "utils/InterfaceList.cpp", @@ -825,6 +826,7 @@ cpp_library( "commands/config/rollback/CmdConfigRollback.h", "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", + "commands/config/session/CmdConfigSessionRebase.h", "session/ConfigSession.h", "session/Git.h", "utils/InterfaceList.h", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 515f4dbb239e4..fcb72aa0d9bb3 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -12,6 +12,7 @@ // Current linter doesn't properly handle the template functions which need the // following headers +// NOLINTBEGIN(misc-include-cleaner) // @lint-ignore-every CLANGTIDY facebook-unused-include-check #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" @@ -27,6 +28,8 @@ #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" +#include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h" +// NOLINTEND(misc-include-cleaner) namespace facebook::fboss { @@ -54,6 +57,8 @@ template void CmdHandler::run(); template void CmdHandler::run(); +template void +CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 697111d4daa13..7f10abbfc0bd1 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -25,6 +25,7 @@ #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" +#include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h" namespace facebook::fboss { @@ -110,6 +111,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, }}, }, diff --git a/fboss/cli/fboss2/cli_metadata.thrift b/fboss/cli/fboss2/cli_metadata.thrift index 97247c320fc7d..bfe0749b2d478 100644 --- a/fboss/cli/fboss2/cli_metadata.thrift +++ b/fboss/cli/fboss2/cli_metadata.thrift @@ -35,4 +35,7 @@ struct ConfigSessionMetadata { // List of CLI commands executed in this session, in chronological order. // Each entry is the full command string (e.g., "config interface eth1/1/1 mtu 9000"). 2: list commands; + // Git commit SHA that this session is based on. Used to detect if someone + // else committed changes while this session was in progress. + 3: string base; } 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..3abb5de2da19b --- /dev/null +++ b/fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.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 "fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h" +#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; +} + +} // 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/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index 6fd9c4e1ed222..96ea1b6614e92 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -12,7 +12,9 @@ #include #include +#include #include +#include #include #include #include @@ -20,14 +22,31 @@ #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" // NOLINT(misc-include-cleaner) +#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; @@ -148,6 +167,117 @@ std::string readCommandLineFromProc() { return folly::join(" ", args); } +// 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; + } + + // 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() @@ -348,6 +478,7 @@ void ConfigSession::loadMetadata() { 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(); @@ -362,6 +493,7 @@ void ConfigSession::saveMetadata() { 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); @@ -496,7 +628,11 @@ void ConfigSession::initializeSession() { // Ensure the session config directory exists ensureDirectoryExists(sessionConfigDir_); copySystemConfigToSession(); - // Create initial empty metadata file for new sessions + // 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) @@ -521,7 +657,7 @@ void ConfigSession::initializeGit() { } } -void ConfigSession::copySystemConfigToSession() { +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. @@ -543,6 +679,20 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { "No config session exists. Make a config change first."); } + // 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( + "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))); + } + std::string cliConfigDir = getCliConfigDir(); std::string cliConfigPath = getCliConfigPath(); std::string sessionConfigPath = getSessionConfigPath(); @@ -672,6 +822,72 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { return CommitResult{commitSha, actionLevel}; } +void ConfigSession::rebase() { + if (!sessionExists()) { + throw std::runtime_error( + "No config session exists. Make a config change first."); + } + + std::string currentHead = git_->getHead(); + + // If base is empty or already matches HEAD, nothing to rebase + if (base_.empty() || base_ == currentHead) { + throw std::runtime_error( + "No rebase needed: session is already based on the current HEAD."); + } + + // 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( + "Failed to read session config from {}", sessionConfigPath)); + } + + // 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( + "Rebase failed due to conflicts at the following paths:{}", + conflictList)); + } + + // Write the merged config to the session file + std::string mergedConfigStr = folly::toPrettyJson(mergedJson); + folly::writeFileAtomic( + sessionConfigPath, mergedConfigStr, 0644, folly::SyncType::WITH_SYNC); + + // 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); diff --git a/fboss/cli/fboss2/session/ConfigSession.h b/fboss/cli/fboss2/session/ConfigSession.h index 73f3a1ffa2a1b..bb49c4bb7a977 100644 --- a/fboss/cli/fboss2/session/ConfigSession.h +++ b/fboss/cli/fboss2/session/ConfigSession.h @@ -13,6 +13,7 @@ #include #include #include +#include #include "fboss/agent/gen-cpp2/agent_config_types.h" #include "fboss/cli/fboss2/gen-cpp2/cli_metadata_types.h" #include "fboss/cli/fboss2/session/Git.h" @@ -120,6 +121,13 @@ class ConfigSession { // Returns CommitResult with git commit SHA and action level. CommitResult commit(const HostInfo& hostInfo); + // 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 @@ -203,6 +211,11 @@ class ConfigSession { // 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; @@ -221,7 +234,7 @@ 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 diff --git a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp index e8977c8d88845..07d1556760611 100644 --- a/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigSessionTest.cpp @@ -75,6 +75,12 @@ class ConfigSessionTestFixture : public CmdHandlerTestBase { "name": "eth1/1/1", "state": 2, "speed": 100000 + }, + { + "logicalID": 2, + "name": "eth1/1/2", + "state": 2, + "speed": 100000 } ] } @@ -883,4 +889,260 @@ TEST_F(ConfigSessionTestFixture, commandTrackingLoadsFromMetadataFile) { 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/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()) From c5e8e1b6938d74382d78e53b670e4afa421e3b56 Mon Sep 17 00:00:00 2001 From: Manoharan Sundaramoorthy Date: Wed, 28 Jan 2026 04:26:35 +0530 Subject: [PATCH 07/18] Add CLI command for managing static MAC entries on VLANs Add new fboss2-dev CLI commands for managing static MAC address entries on VLANs: - `config vlan static-mac add ` - `config vlan static-mac delete ` These commands allow operators to configure static MAC address entries in the FBOSS switch configuration, which is useful for scenarios where MAC addresses need to be pinned to specific ports. Features: * VLAN ID validation (1-4094 range) * MAC address format validation * Port existence validation with automatic logical ID resolution * Duplicate entry detection for add operations * Integration with ConfigSession for proper config management Unit Tests: ``` ./fboss/oss/scripts/nhfboss-test.sh --timeout 30 --retry 0 --filter 'CmdConfigVlanStaticMac' Output: [==========] Running 33 tests from 1 test suite. ... [ PASSED ] 33 tests. ``` Manual CLI Testing: ``` $ fboss2-dev config vlan 2001 static-mac add 02:00:00:E2:E2:01 eth1/1/1 Successfully added static MAC entry: MAC 02:00:00:E2:E2:01 -> VLAN 2001, Port eth1/1/1 (ID 9) $ fboss2-dev config session commit Config session committed successfully as r77 and config reloaded. $ fboss2-dev config vlan 2001 static-mac delete 02:00:00:E2:E2:01 Successfully deleted static MAC entry: MAC 02:00:00:E2:E2:01 from VLAN 2001 ``` --- cmake/CliFboss2.cmake | 6 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 6 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 12 + fboss/cli/fboss2/CmdListConfig.cpp | 30 ++ fboss/cli/fboss2/CmdSubcommands.cpp | 6 + .../CmdConfigInterfaceSwitchportAccessVlan.h | 37 +- .../commands/config/vlan/CmdConfigVlan.h | 43 ++ .../vlan/static_mac/CmdConfigVlanStaticMac.h | 42 ++ .../add/CmdConfigVlanStaticMacAdd.cpp | 88 ++++ .../add/CmdConfigVlanStaticMacAdd.h | 80 ++++ .../delete/CmdConfigVlanStaticMacDelete.cpp | 82 ++++ .../delete/CmdConfigVlanStaticMacDelete.h | 74 ++++ fboss/cli/fboss2/test/BUCK | 1 + .../test/CmdConfigVlanStaticMacTest.cpp | 411 ++++++++++++++++++ fboss/cli/fboss2/utils/CmdUtils.h | 37 +- fboss/cli/fboss2/utils/CmdUtilsCommon.h | 1 + .../cli_tests/test_config_vlan_static_mac.py | 142 ++++++ 18 files changed, 1063 insertions(+), 36 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h create mode 100644 fboss/cli/fboss2/commands/config/vlan/static_mac/CmdConfigVlanStaticMac.h create mode 100644 fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp create mode 100644 fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h create mode 100644 fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp create mode 100644 fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h create mode 100644 fboss/cli/fboss2/test/CmdConfigVlanStaticMacTest.cpp create mode 100755 fboss/oss/cli_tests/test_config_vlan_static_mac.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index d2937ed7a3f23..1d7504791c456 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -612,6 +612,12 @@ add_library(fboss2_config_lib 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.h + 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/session/Git.h diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index 9940f3572ba7d..d8076c99357ac 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -44,6 +44,7 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/CmdConfigReloadTest.cpp fboss/cli/fboss2/test/CmdConfigSessionDiffTest.cpp fboss/cli/fboss2/test/CmdConfigSessionTest.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 diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 4007bff2e092e..515e6bfc53fc0 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -807,6 +807,8 @@ cpp_library( "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", "commands/config/session/CmdConfigSessionRebase.cpp", + "commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp", + "commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp", "session/ConfigSession.cpp", "session/Git.cpp", "utils/InterfaceList.cpp", @@ -827,6 +829,10 @@ cpp_library( "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", "commands/config/session/CmdConfigSessionRebase.h", + "commands/config/vlan/CmdConfigVlan.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/InterfaceList.h", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index fcb72aa0d9bb3..20db3bc2318db 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -29,6 +29,10 @@ #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/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" // NOLINTEND(misc-include-cleaner) namespace facebook::fboss { @@ -62,5 +66,13 @@ 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< + CmdConfigVlanStaticMacDelete, + CmdConfigVlanStaticMacDeleteTraits>::run(); } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 7f10abbfc0bd1..c4d20b832e822 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -26,6 +26,10 @@ #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/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 { @@ -131,6 +135,32 @@ const CommandTree& kConfigCommandTree() { "Rollback to a previous config revision", commandHandler, argTypeHandler}, + + { + "config", + "vlan", + "Configure VLAN settings", + 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 f457b94d73513..4ddfd8710d362 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -242,6 +242,12 @@ CLI::App* CmdSubcommands::addCommand( 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_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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 index e2af310574b50..584dd1fd6f450 100644 --- a/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h @@ -10,8 +10,6 @@ #pragma once -#include -#include #include "fboss/cli/fboss2/CmdHandler.h" #include "fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h" #include "fboss/cli/fboss2/utils/CmdUtils.h" @@ -19,39 +17,8 @@ namespace facebook::fboss { -// Custom type for VLAN ID argument with validation -class VlanIdValue : public utils::BaseObjectArgType { - public: - /* implicit */ VlanIdValue(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 utils::ObjectArgTypeId id = - utils::ObjectArgTypeId::OBJECT_ARG_TYPE_VLAN_ID; -}; +// Use VlanIdValue from CmdUtils.h +using VlanIdValue = utils::VlanIdValue; struct CmdConfigInterfaceSwitchportAccessVlanTraits : public WriteCommandTraits { 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/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..89eaac1bbb3e0 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp @@ -0,0 +1,88 @@ +/* + * 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 +#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; +} + +} // 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..cca2643be6bc1 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp @@ -0,0 +1,82 @@ +/* + * 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 +#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; +} + +} // 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/test/BUCK b/fboss/cli/fboss2/test/BUCK index 9139c24c8eee9..72a636ff9704d 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -72,6 +72,7 @@ cpp_unittest( "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", + "CmdConfigVlanStaticMacTest.cpp", "CmdGetPcapTest.cpp", "CmdListConfigTest.cpp", "CmdSetPortStateTest.cpp", 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/utils/CmdUtils.h b/fboss/cli/fboss2/utils/CmdUtils.h index fed01fabc6855..ee8ee0d84a1c4 100644 --- a/fboss/cli/fboss2/utils/CmdUtils.h +++ b/fboss/cli/fboss2/utils/CmdUtils.h @@ -144,9 +144,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 384deef7a7cdd..100c7751a2d85 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -68,6 +68,7 @@ enum class ObjectArgTypeId : uint8_t { OBJECT_ARG_TYPE_ID_REVISION_LIST, OBJECT_ARG_TYPE_ID_BUFFER_POOL_NAME, OBJECT_ARG_TYPE_VLAN_ID, + OBJECT_ARG_TYPE_MAC_AND_PORT, }; template 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()) From 73d5198e066d26dc75289d257ca0d9157652b8ba Mon Sep 17 00:00:00 2001 From: benoit-nexthop Date: Wed, 28 Jan 2026 17:19:47 +0100 Subject: [PATCH 08/18] NOS-4057: Add show running-config CLI command (#382) Add a new fboss2 show command that retrieves the running configuration from the agent via the `getRunningConfig()` Thrift API and displays it as nicely formatted JSON. This is useful when the config file on disk is missing or out of sync with the running agent configuration. # Test Plan Added unit test CmdShowRunningConfigTest that mocks the getRunningConfig thrift API. ## Sample usage ``` [admin@fboss101 ~]$ fboss2 show running-config { "sw": { "version": 0, "ports": [ { "logicalID": 1, "speed": "HUNDREDG", "name": "eth1/1/1", ... } ], ... } } ``` NOS-4057 #done --- cmake/CliFboss2.cmake | 2 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 2 + fboss/cli/fboss2/CmdHandlerImpl.cpp | 7 ++++ fboss/cli/fboss2/CmdList.cpp | 7 ++++ .../running_config/CmdShowRunningConfig.cpp | 41 ++++++++++++++++++ .../running_config/CmdShowRunningConfig.h | 41 ++++++++++++++++++ fboss/cli/fboss2/test/BUCK | 1 + .../fboss2/test/CmdShowRunningConfigTest.cpp | 42 +++++++++++++++++++ 9 files changed, 144 insertions(+) create mode 100644 fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.cpp create mode 100644 fboss/cli/fboss2/commands/show/running_config/CmdShowRunningConfig.h create mode 100644 fboss/cli/fboss2/test/CmdShowRunningConfigTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 1d7504791c456..81a6f8117826f 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -473,6 +473,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/systemport/CmdShowSystemPort.h fboss/cli/fboss2/commands/show/systemport/CmdShowSystemPort.cpp diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index d8076c99357ac..b28b3f6269836 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -74,6 +74,7 @@ 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 diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 515e6bfc53fc0..e6180c6eaa0b5 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -330,6 +330,7 @@ cpp_library( "commands/show/port/CmdShowPortQueue.cpp", "commands/show/rif/CmdShowRif.cpp", "commands/show/route/utils.cpp", + "commands/show/running_config/CmdShowRunningConfig.cpp", "commands/show/systemport/CmdShowSystemPort.cpp", "commands/show/transceiver/CmdShowTransceiver.cpp", "facebook/CmdHandlerImpl.cpp", @@ -504,6 +505,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", 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/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/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/test/BUCK b/fboss/cli/fboss2/test/BUCK index 72a636ff9704d..7fda701a7351f 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -102,6 +102,7 @@ cpp_unittest( "CmdShowProductTest.cpp", "CmdShowRouteDetailsTest.cpp", "CmdShowRouteSummaryTest.cpp", + "CmdShowRunningConfigTest.cpp", "CmdShowTeFlowTest.cpp", "CmdShowTransceiverTest.cpp", "CmdStartPcapTest.cpp", 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 From a46a1d0a46c96f2ce8fde1a1efdfd458be24a7ea Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Sat, 24 Jan 2026 01:02:33 +0000 Subject: [PATCH 09/18] Add CLI commands for configuring priority group policies Implements new fboss2-dev CLI commands for configuring PFC priority group policies (`portPgConfigs` in `SwitchConfig`). This allows configuring per-port priority group settings for traffic prioritization and buffer management. New command: `config qos priority-group-policy group-id [ ...]` Where can be: - `min-limit-bytes` - `headroom-limit-bytes` - `resume-offset-bytes` - `static-limit-bytes` - `scaling-factor` (accepts `MMUScalingFactor` enum values like `ONE_HALF`, `TWO`, etc) - `buffer-pool-name` Multiple attributes can be set in a single command, or one at a time. The implementation doesn't declare the sub-subcommands in a conventional way in order to avoid an explosion of `.h` / `.cpp` pairs. Also updates `ConfigSession::restartAgent()` to use `sudo` for `systemctl restart` since the CLI may be run by non-root users. New end to end tests: ``` ====================================================================== CLI E2E Test: PFC Configuration ====================================================================== [Step 0] Cleaning up any existing test config... Copying system config to session config... Removing PFC-related configs... Updating metadata for AGENT_RESTART... Committing cleanup... Using CLI from FBOSS_CLI_PATH: /home/admin/benoit/fboss2-dev [CLI] Running: config session commit [CLI] Completed in 4.90s: config session commit Cleanup complete [Step 1] Configuring buffer pool 'cli_e2e_test_buffer_pool'... [CLI] Running: config qos buffer-pool cli_e2e_test_buffer_pool shared-bytes 78773528 [CLI] Completed in 0.08s: config qos buffer-pool cli_e2e_test_buffer_pool shared-bytes 78773528 [CLI] Running: config qos buffer-pool cli_e2e_test_buffer_pool headroom-bytes 4405376 [CLI] Completed in 0.07s: config qos buffer-pool cli_e2e_test_buffer_pool headroom-bytes 4405376 Buffer pool configured [Step 2] Configuring priority group policy 'cli_e2e_test_pg_policy'... Using single-attribute commands for group-ids 2 and 6... Configuring group-id 2 (one attribute at a time)... [CLI] Running: config qos priority-group-policy cli_e2e_test_pg_policy group-id 2 min-limit-bytes 14478 [CLI] Completed in 0.08s: config qos priority-group-policy cli_e2e_test_pg_policy group-id 2 min-limit-bytes 14478 ... Using multi-attribute commands for group-ids 0 and 7... Configuring group-id 0 (all attributes at once)... [CLI] Running: config qos priority-group-policy cli_e2e_test_pg_policy group-id 0 min-limit-bytes 4826 headroom-limit-bytes 0 resume-offset-bytes 9652 scaling-factor TWO buffer-pool-name cli_e2e_test_buffer_pool [CLI] Completed in 0.08s: config qos priority-group-policy cli_e2e_test_pg_policy group-id 0 min-limit-bytes 4826 headroom-limit-bytes 0 resume-offset-bytes 9652 scaling-factor TWO buffer-pool-name cli_e2e_test_buffer_pool Configuring group-id 7 (all attributes at once)... [CLI] Running: config qos priority-group-policy cli_e2e_test_pg_policy group-id 7 min-limit-bytes 4826 headroom-limit-bytes 0 resume-offset-bytes 9652 scaling-factor TWO buffer-pool-name cli_e2e_test_buffer_pool [CLI] Completed in 0.07s: config qos priority-group-policy cli_e2e_test_pg_policy group-id 7 min-limit-bytes 4826 headroom-limit-bytes 0 resume-offset-bytes 9652 scaling-factor TWO buffer-pool-name cli_e2e_test_buffer_pool All priority groups configured [Step 3] Committing configuration... [CLI] Running: config session commit [CLI] Completed in 15.55s: config session commit Configuration committed successfully [Step 4] Verifying configuration... Buffer pool 'cli_e2e_test_buffer_pool' verified Priority group policy 'cli_e2e_test_pg_policy' verified ====================================================================== TEST PASSED ====================================================================== [Step 5] Cleaning up test config... Copying system config to session config... Removing PFC-related configs... Updating metadata for AGENT_RESTART... Committing cleanup... [CLI] Running: config session commit [CLI] Completed in 6.67s: config session commit Cleanup complete ``` ``` [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos buffer-pool sample_buffer_pool shared-bytes 78773528 Successfully set shared-bytes for buffer-pool 'sample_buffer_pool' to 78773528 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos buffer-pool sample_buffer_pool headroom-bytes 4405376 Successfully set headroom-bytes for buffer-pool 'sample_buffer_pool' to 4405376 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos priority-group-policy sample_pg_policy group-id 2 min-limit-bytes 14478 headroom-limit-bytes 726440 resume-offset-bytes 9652 scaling-factor ONE_HALF buffer-pool-name sample_buffer_pool Successfully configured priority-group-policy 'sample_pg_policy' group-id 2 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config session diff --- current live config +++ session config @@ -121,6 +121,12 @@ "arpAgerInterval": 5, "arpRefreshSeconds": 20, "arpTimeoutSeconds": 60, + "bufferPoolConfigs": { + "sample_buffer_pool": { + "headroomBytes": 4405376, + "sharedBytes": 78773528 + } + }, "clientIdToAdminDistance": { "0": 20, "1": 1, @@ -2196,6 +2202,19 @@ "maxNeighborProbes": 300, "mirrorOnDropReports": [], "mirrors": [], + "portPgConfigs": { + "sample_pg_policy": [ + { + "bufferPoolName": "sample_buffer_pool", + "headroomLimitBytes": 726440, + "id": 2, + "minLimitBytes": 14478, + "name": "pg2", + "resumeOffsetBytes": 9652, + "sramScalingFactor": 8 + } + ] + }, "portQueueConfigs": {}, "ports": [ { ``` --- cmake/CliFboss2.cmake | 3 + fboss/cli/fboss2/BUCK | 3 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 10 + fboss/cli/fboss2/CmdListConfig.cpp | 22 +- fboss/cli/fboss2/CmdSubcommands.cpp | 28 ++ .../CmdConfigQosPriorityGroupPolicy.h | 89 ++++++ ...CmdConfigQosPriorityGroupPolicyGroupId.cpp | 195 ++++++++++++ .../CmdConfigQosPriorityGroupPolicyGroupId.h | 81 +++++ fboss/cli/fboss2/session/ConfigSession.cpp | 5 +- fboss/cli/fboss2/utils/CmdUtilsCommon.h | 13 +- fboss/oss/cli_tests/test_config_pfc.py | 299 ++++++++++++++++++ 11 files changed, 739 insertions(+), 9 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h create mode 100644 fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp create mode 100644 fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h create mode 100644 fboss/oss/cli_tests/test_config_pfc.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 81a6f8117826f..2560340e201ad 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -604,6 +604,9 @@ add_library(fboss2_config_lib 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/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/history/CmdConfigHistory.h fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index e6180c6eaa0b5..8d83409418766 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -805,6 +805,7 @@ cpp_library( "commands/config/interface/CmdConfigInterfaceMtu.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", @@ -827,6 +828,8 @@ cpp_library( "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h", "commands/config/qos/CmdConfigQos.h", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h", + "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h", "commands/config/rollback/CmdConfigRollback.h", "commands/config/session/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 20db3bc2318db..1785517ec971a 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -12,6 +12,7 @@ // Current linter doesn't properly handle the template functions which need the // following headers +// IWYU pragma: begin_keep // NOLINTBEGIN(misc-include-cleaner) // @lint-ignore-every CLANGTIDY facebook-unused-include-check #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" @@ -25,6 +26,8 @@ #include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -34,6 +37,7 @@ #include "fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h" #include "fboss/cli/fboss2/commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.h" // NOLINTEND(misc-include-cleaner) +// IWYU pragma: end_keep namespace facebook::fboss { @@ -74,5 +78,11 @@ CmdHandler::run(); template void CmdHandler< CmdConfigVlanStaticMacDelete, CmdConfigVlanStaticMacDeleteTraits>::run(); +template void CmdHandler< + CmdConfigQosPriorityGroupPolicy, + CmdConfigQosPriorityGroupPolicyTraits>::run(); +template void CmdHandler< + CmdConfigQosPriorityGroupPolicyGroupId, + CmdConfigQosPriorityGroupPolicyGroupIdTraits>::run(); } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index c4d20b832e822..560b0427420a9 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -8,9 +8,9 @@ * */ +#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" @@ -22,6 +22,8 @@ #include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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/priority_group_policy/CmdConfigQosPriorityGroupPolicy.h" +#include "fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.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" @@ -93,11 +95,19 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, {{ - "buffer-pool", - "Configure buffer pool settings", - commandHandler, - argTypeHandler, - }}, + "buffer-pool", + "Configure buffer pool settings", + commandHandler, + argTypeHandler, + }, + {"priority-group-policy", + "Configure priority group policy settings", + commandHandler, + argTypeHandler, + {{"group-id", + "Specify priority group ID (0-7)", + commandHandler, + argTypeHandler}}}}, }, { diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 4ddfd8710d362..ac305cd9b7796 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 {}; @@ -248,6 +255,27 @@ CLI::App* CmdSubcommands::addCommand( 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_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..7bff90cb99388 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp @@ -0,0 +1,195 @@ +/* + * 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 +#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; +} + +} // 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/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index 96ea1b6614e92..b0fc1cb7b8d18 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -564,11 +564,12 @@ void ConfigSession::restartAgent(cli::AgentType agent) { LOG(INFO) << "Restarting " << serviceName << " via systemd..."; - // Use systemctl to restart the service + // Use sudo systemctl to restart the service + // The CLI may be run by non-root users who have sudo access // Using folly::Subprocess with explicit args avoids shell injection try { folly::Subprocess restartProc( - {"/usr/bin/systemctl", "restart", serviceName}); + {"/usr/bin/sudo", "/usr/bin/systemctl", "restart", serviceName}); restartProc.waitChecked(); } catch (const std::exception& ex) { throw std::runtime_error( diff --git a/fboss/cli/fboss2/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 100c7751a2d85..26768acefaf21 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 { @@ -69,6 +77,9 @@ enum class ObjectArgTypeId : uint8_t { 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, }; template 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..80bc191e72ca1 --- /dev/null +++ b/fboss/oss/cli_tests/test_config_pfc.py @@ -0,0 +1,299 @@ +#!/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) + +This test: +1. Cleans up any existing test config (portPgConfigs and bufferPoolConfigs) +2. Creates a buffer pool (required for priority group config) +3. Creates a new priority group policy with multiple group IDs +4. Commits the configuration and verifies it was applied +5. Cleans up the test config +""" + +import json +import os +import shutil +import sys + +from cli_test_lib import commit_config, run_cli + + +# Paths +SYSTEM_CONFIG_PATH = "/etc/coop/agent.conf" +SESSION_CONFIG_PATH = os.path.expanduser("~/.fboss2/agent.conf") + +# Test names +TEST_BUFFER_POOL_NAME = "cli_e2e_test_buffer_pool" +TEST_POLICY_NAME = "cli_e2e_test_pg_policy" + +# 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, + }, +] + + +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 cleanup_test_config() -> None: + """Remove portPgConfigs and bufferPoolConfigs from the config.""" + 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(" Removing PFC-related configs...") + with open(SESSION_CONFIG_PATH, "r") as f: + config = json.load(f) + + sw_config = config.get("sw", {}) + + # Remove global PFC configs + sw_config.pop("portPgConfigs", None) + sw_config.pop("bufferPoolConfigs", None) + + with open(SESSION_CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2) + + # Update metadata to require AGENT_RESTART since we're changing PFC config + # Use symbolic names matching thrift PORTABLE format + 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 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: 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 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") + + print("\n" + "=" * 70) + print("TEST PASSED") + print("=" * 70) + + # Step 5: Cleanup test config + print("\n[Step 5] Cleaning up test config...") + cleanup_test_config() + print(" Cleanup complete") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3a00aa1dd66da355e01b1d30147296a0e717eb89 Mon Sep 17 00:00:00 2001 From: benoit-nexthop Date: Mon, 2 Feb 2026 17:04:31 +0100 Subject: [PATCH 10/18] Add CLI commands for configuring per-port PFC settings # Summary Implements new fboss2-dev CLI commands for configuring PFC (Priority Flow Control) settings on individual interfaces. This allows enabling/disabling PFC, setting watchdog parameters, and associating priority group policies with ports. The command uses a key-value pair syntax that allows setting multiple attributes in a single command: ``` config interface pfc-config [ ...] ``` Valid attributes: - `tx enabled|disabled` - Enable/disable PFC transmission - `rx enabled|disabled` - Enable/disable PFC reception - `tx-duration enabled|disabled` - Enable/disable TX duration - `rx-duration enabled|disabled` - Enable/disable RX duration - `priority-group-policy ` - Set priority group policy - `watchdog-detection-time ` - Set watchdog detection time - `watchdog-recovery-time ` - Set watchdog recovery time - `watchdog-recovery-action drop|no-drop` - Set watchdog recovery action # Test Plan End-to-end tests on fboss101: ``` ====================================================================== CLI E2E Test: PFC Configuration ====================================================================== [Step 0] Cleaning up any existing test config... [...] Cleanup complete [Step 1] Configuring buffer pool 'cli_e2e_test_buffer_pool'... [...] Buffer pool configured [Step 2] Configuring priority group policy 'cli_e2e_test_pg_policy'... [...] All priority groups configured [Step 3] Configuring PFC on port 'eth1/1/1'... Using single-attribute commands for tx, rx, priority-group-policy... [CLI] Running: config interface eth1/1/1 pfc-config tx enabled [CLI] Completed in 0.07s: config interface eth1/1/1 pfc-config tx enabled [CLI] Running: config interface eth1/1/1 pfc-config rx enabled [CLI] Completed in 0.06s: config interface eth1/1/1 pfc-config rx enabled [CLI] Running: config interface eth1/1/1 pfc-config priority-group-policy cli_e2e_test_pg_policy [CLI] Completed in 0.06s: config interface eth1/1/1 pfc-config priority-group-policy cli_e2e_test_pg_policy Using multi-attribute command for all watchdog settings... [CLI] Running: config interface eth1/1/1 pfc-config watchdog-detection-time 150 watchdog-recovery-time 1000 watchdog-recovery-action no-drop [CLI] Completed in 0.06s: config interface eth1/1/1 pfc-config watchdog-detection-time 150 watchdog-recovery-time 1000 watchdog-recovery-action no-drop Port PFC configured [Step 4] Committing configuration... [CLI] Running: config session commit [CLI] Completed in 11.84s: config session commit Configuration committed successfully [Step 5] Verifying configuration... Buffer pool 'cli_e2e_test_buffer_pool' verified Priority group policy 'cli_e2e_test_pg_policy' verified Port 'eth1/1/1' PFC config verified ====================================================================== TEST PASSED ====================================================================== [Step 6] Cleaning up test config... [...] Cleanup complete ``` ## Sample usage Setting multiple attributes at once: ``` [admin@fboss101 ~]$ ~/benoit/fboss2-dev config interface eth1/1/1 pfc-config tx enabled rx enabled priority-group-policy sample_pg_policy Successfully configured PFC for interface(s) eth1/1/1 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config interface eth1/1/1 pfc-config watchdog-detection-time 150 watchdog-recovery-time 1000 watchdog-recovery-action no-drop Successfully configured PFC for interface(s) eth1/1/1 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config session diff --- current live config +++ session config @@ -2218,6 +2218,16 @@ "rx": false, "tx": false }, + "pfc": { + "portPgConfigName": "sample_pg_policy", + "rx": true, + "tx": true, + "watchdog": { + "detectionTimeMsecs": 150, + "recoveryAction": 0, + "recoveryTimeMsecs": 1000 + } + }, "portType": 0, "profileID": 39, "queues_DEPRECATED": [], ``` --- cmake/CliFboss2.cmake | 9 +- fboss/cli/fboss2/BUCK | 3 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 4 + fboss/cli/fboss2/CmdListConfig.cpp | 7 + fboss/cli/fboss2/CmdSubcommands.cpp | 9 + .../CmdConfigInterfacePfcConfig.cpp | 184 ++++++++++++++++++ .../pfc_config/CmdConfigInterfacePfcConfig.h | 48 +++++ .../interface/pfc_config/PfcConfigUtils.h | 43 ++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 14 +- fboss/cli/fboss2/utils/InterfaceList.cpp | 7 +- fboss/cli/fboss2/utils/InterfaceList.h | 15 +- fboss/oss/cli_tests/test_config_pfc.py | 102 +++++++++- 12 files changed, 423 insertions(+), 22 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp create mode 100644 fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h create mode 100644 fboss/cli/fboss2/commands/config/interface/pfc_config/PfcConfigUtils.h diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 2560340e201ad..b750bda0af3b2 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -593,14 +593,17 @@ add_library(fboss2_config_lib fboss/cli/fboss2/commands/config/CmdConfigReload.h fboss/cli/fboss2/commands/config/CmdConfigReload.cpp fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h - fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp - fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp + fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.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.h fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h - fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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/qos/CmdConfigQos.h fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 8d83409418766..9247551817db8 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -803,6 +803,7 @@ cpp_library( "commands/config/history/CmdConfigHistory.cpp", "commands/config/interface/CmdConfigInterfaceDescription.cpp", "commands/config/interface/CmdConfigInterfaceMtu.cpp", + "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", @@ -823,6 +824,8 @@ cpp_library( "commands/config/interface/CmdConfigInterface.h", "commands/config/interface/CmdConfigInterfaceDescription.h", "commands/config/interface/CmdConfigInterfaceMtu.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", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 1785517ec971a..3fec9837fb4a5 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -21,6 +21,7 @@ #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/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" @@ -50,6 +51,9 @@ template void CmdHandler< CmdConfigInterfaceDescriptionTraits>::run(); template void CmdHandler::run(); +template void CmdHandler< + CmdConfigInterfacePfcConfig, + CmdConfigInterfacePfcConfigTraits>::run(); template void CmdHandler< CmdConfigInterfaceSwitchport, CmdConfigInterfaceSwitchportTraits>::run(); diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 560b0427420a9..5a46e8adc77f1 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -17,6 +17,7 @@ #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/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" @@ -67,6 +68,12 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, }, + { + "pfc-config", + "Configure PFC settings for interface", + commandHandler, + argTypeHandler, + }, { "switchport", "Configure switchport settings", diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index ac305cd9b7796..a45af1b2ca41b 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -276,6 +276,15 @@ CLI::App* CmdSubcommands::addCommand( "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_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..1cfeac6d3df2f --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp @@ -0,0 +1,184 @@ +/* + * 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 +#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/InterfaceList.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 InterfaceList& interfaces, + const ObjectArgType& config) { + 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; +} + +} // 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..22cf278b76e17 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#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/InterfaceList.h" + +namespace facebook::fboss { + +using InterfaceList = utils::InterfaceList; + +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 InterfaceList& interfaces, + 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/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 26768acefaf21..6dd6f5583468b 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -80,18 +80,21 @@ enum class ObjectArgTypeId : uint8_t { 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, }; 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_; } @@ -130,8 +133,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; }; @@ -197,7 +201,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/oss/cli_tests/test_config_pfc.py b/fboss/oss/cli_tests/test_config_pfc.py index 80bc191e72ca1..0b9bc68b4056e 100644 --- a/fboss/oss/cli_tests/test_config_pfc.py +++ b/fboss/oss/cli_tests/test_config_pfc.py @@ -9,13 +9,15 @@ 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 and bufferPoolConfigs) +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. Commits the configuration and verifies it was applied -5. Cleans up the test config +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 @@ -33,6 +35,7 @@ # 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 = { @@ -117,6 +120,19 @@ }, ] +# 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.""" @@ -171,8 +187,38 @@ def configure_priority_group_multi_attr( 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 portPgConfigs and bufferPoolConfigs from the config.""" + """Remove PFC-related test config: portPgConfigs, bufferPoolConfigs, and port pfc.""" session_dir = os.path.dirname(SESSION_CONFIG_PATH) metadata_path = os.path.join(session_dir, "cli_metadata.json") @@ -190,6 +236,13 @@ def cleanup_test_config() -> None: 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 + with open(SESSION_CONFIG_PATH, "w") as f: json.dump(config, f, indent=2) @@ -241,13 +294,18 @@ def main() -> int: ) print(" All priority groups configured") - # Step 3: Commit the configuration - print("\n[Step 3] Committing configuration...") + # 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 4: Verify configuration by reading /etc/coop/agent.conf - print("\n[Step 4] Verifying configuration...") + # 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) @@ -283,12 +341,36 @@ def main() -> int: 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 5: Cleanup test config - print("\n[Step 5] Cleaning up test config...") + # Step 6: Cleanup test config + print("\n[Step 6] Cleaning up test config...") cleanup_test_config() print(" Cleanup complete") From 79c607ddea3e5c671bee5d91276bf73e620010e1 Mon Sep 17 00:00:00 2001 From: benoit-nexthop Date: Mon, 2 Feb 2026 17:25:27 +0100 Subject: [PATCH 11/18] Add CLI commands for configuring port queue configs (queuing-policy) # Summary Implements new fboss2-dev CLI commands for configuring port queue configs (PortQueue) under queuing policies. This allows setting queue scheduling, weights, buffer sizes, and other queue-related parameters. New commands: - `config qos queuing-policy queue-id scheduling ` - `config qos queuing-policy queue-id weight ` - `config qos queuing-policy queue-id shared-bytes ` - `config qos queuing-policy queue-id reserved-bytes ` - `config qos queuing-policy queue-id scaling-factor ` - `config qos queuing-policy queue-id buffer-pool-name ` # Test Plan New end to end tests: ``` ====================================================================== CLI E2E Test: Port Queue Configuration ====================================================================== [Step 0] Cleaning up any existing test config... Copying system config to session config... Removing port queue configs... Updating metadata for AGENT_RESTART... Committing cleanup... Using CLI from FBOSS_CLI_PATH: /home/admin/benoit/fboss2-dev [CLI] Running: config session commit [CLI] Completed in 4.77s: config session commit Cleanup complete [Step 1] Configuring buffer pool 'cli_e2e_test_buffer_pool'... [CLI] Running: config qos buffer-pool cli_e2e_test_buffer_pool shared-bytes 78773528 [CLI] Completed in 0.07s: config qos buffer-pool cli_e2e_test_buffer_pool shared-bytes 78773528 [CLI] Running: config qos buffer-pool cli_e2e_test_buffer_pool headroom-bytes 4405376 [CLI] Completed in 0.06s: config qos buffer-pool cli_e2e_test_buffer_pool headroom-bytes 4405376 Buffer pool configured [Step 2] Configuring queuing policy 'cli_e2e_test_queue_policy'... Configuring queue-id 2... [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 scheduling SP [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 scheduling SP [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 weight 10 [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 weight 10 [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 shared-bytes 83618832 [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 shared-bytes 83618832 Configuring queue-id 6... [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 scheduling SP [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 scheduling SP [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 shared-bytes 83618832 [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 shared-bytes 83618832 [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 scaling-factor TWO [CLI] Completed in 0.08s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 6 scaling-factor TWO Configuring queue-id 7... [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 scheduling WRR [CLI] Completed in 0.08s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 scheduling WRR [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 weight 20 [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 weight 20 [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 reserved-bytes 5000 [CLI] Completed in 0.06s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 reserved-bytes 5000 [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 buffer-pool-name cli_e2e_test_buffer_pool [CLI] Completed in 0.07s: config qos queuing-policy cli_e2e_test_queue_policy queue-id 7 buffer-pool-name cli_e2e_test_buffer_pool All queues configured [Step 3] Committing configuration... [CLI] Running: config session commit [CLI] Completed in 15.79s: config session commit Configuration committed successfully [Step 4] Verifying configuration... Buffer pool 'cli_e2e_test_buffer_pool' verified Queuing policy 'cli_e2e_test_queue_policy' verified ====================================================================== TEST PASSED ====================================================================== [Step 5] Cleaning up test config... Copying system config to session config... Removing port queue configs... Updating metadata for AGENT_RESTART... Committing cleanup... [CLI] Running: config session commit [CLI] Completed in 6.37s: config session commit Cleanup complete ``` ## Sample usage ``` [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 scheduling SP Successfully set scheduling for queuing-policy 'test-queue' queue-id 0 to SP [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 weight 10 Successfully set weight for queuing-policy 'test-queue' queue-id 0 to 10 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 shared-bytes 83618832 Successfully set shared-bytes for queuing-policy 'test-queue' queue-id 0 to 83618832 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 reserved-bytes 5000 Successfully set reserved-bytes for queuing-policy 'test-queue' queue-id 0 to 5000 [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 scaling-factor TWO Successfully set scaling-factor for queuing-policy 'test-queue' queue-id 0 to TWO [admin@fboss101 ~]$ ~/benoit/fboss2-dev config qos queuing-policy test-queue queue-id 0 buffer-pool-name ingress_pool Successfully set buffer-pool-name for queuing-policy 'test-queue' queue-id 0 to ingress_pool [admin@fboss101 ~]$ ~/benoit/fboss2-dev config session diff --- current live config +++ session config @@ -2197,7 +2197,20 @@ "maxNeighborProbes": 300, "mirrorOnDropReports": [], "mirrors": [], - "portQueueConfigs": {}, + "portQueueConfigs": { + "test-queue": [ + { + "bufferPoolName": "ingress_pool", + "id": 0, + "reservedBytes": 5000, + "scalingFactor": 9, + "scheduling": 1, + "sharedBytes": 83618832, + "streamType": 0, + "weight": 10 + } + ] + }, "ports": [ { "conditionalEntropyRehash": false, ``` --- cmake/CliFboss2.cmake | 3 + fboss/cli/fboss2/BUCK | 3 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 7 + fboss/cli/fboss2/CmdListConfig.cpp | 32 ++- fboss/cli/fboss2/CmdSubcommands.cpp | 13 + .../CmdConfigQosQueuingPolicy.h | 86 ++++++ .../CmdConfigQosQueuingPolicyQueueId.cpp | 244 +++++++++++++++++ .../CmdConfigQosQueuingPolicyQueueId.h | 79 ++++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 3 + fboss/oss/cli_tests/cli_test_lib.py | 58 ++++ fboss/oss/cli_tests/test_config_pfc.py | 63 ++--- .../oss/cli_tests/test_config_portqueuecfg.py | 252 ++++++++++++++++++ 12 files changed, 790 insertions(+), 53 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicy.h create mode 100644 fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp create mode 100644 fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h create mode 100644 fboss/oss/cli_tests/test_config_portqueuecfg.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index b750bda0af3b2..e5ccd5404e933 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -610,6 +610,9 @@ add_library(fboss2_config_lib 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.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 diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 9247551817db8..129f24498d3b0 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -807,6 +807,7 @@ cpp_library( "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", + "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", @@ -833,6 +834,8 @@ cpp_library( "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.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/CmdConfigSessionCommit.h", "commands/config/session/CmdConfigSessionDiff.h", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 3fec9837fb4a5..de58702d05292 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -29,6 +29,8 @@ #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.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/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -88,5 +90,10 @@ template void CmdHandler< template void CmdHandler< CmdConfigQosPriorityGroupPolicyGroupId, CmdConfigQosPriorityGroupPolicyGroupIdTraits>::run(); +template void +CmdHandler::run(); +template void CmdHandler< + CmdConfigQosQueuingPolicyQueueId, + CmdConfigQosQueuingPolicyQueueIdTraits>::run(); } // namespace facebook::fboss diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 5a46e8adc77f1..2c04dd82f606d 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -25,6 +25,8 @@ #include "fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.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/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -107,14 +109,28 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, }, - {"priority-group-policy", - "Configure priority group policy settings", - commandHandler, - argTypeHandler, - {{"group-id", - "Specify priority group ID (0-7)", - 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, + }}, + }}, }, { diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index a45af1b2ca41b..d73f98c9f290e 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -285,6 +285,19 @@ CLI::App* CmdSubcommands::addCommand( "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"); + break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..c3128c4a44453 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp @@ -0,0 +1,244 @@ +/* + * 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 +#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); +} + +// 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; +} + +// 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"); + } + + // 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 key-value pairs + // After the queue 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 queue 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]); + } +} + +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"); + } + } + + // 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; +} + +} // 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..d532ba7bdb3a5 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#pragma once + +#include +#include +#include +#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 static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QUEUE_ID; + + private: + int16_t queueId_{0}; + std::vector> attributes_; +}; + +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/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 6dd6f5583468b..45f0bc84b6864 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -82,6 +82,9 @@ enum class ObjectArgTypeId : uint8_t { 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, }; template diff --git a/fboss/oss/cli_tests/cli_test_lib.py b/fboss/oss/cli_tests/cli_test_lib.py index be38ebcf1ec2b..6e9c0db3c12a2 100644 --- a/fboss/oss/cli_tests/cli_test_lib.py +++ b/fboss/oss/cli_tests/cli_test_lib.py @@ -239,3 +239,61 @@ 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() diff --git a/fboss/oss/cli_tests/test_config_pfc.py b/fboss/oss/cli_tests/test_config_pfc.py index 0b9bc68b4056e..9c4b284447d8d 100644 --- a/fboss/oss/cli_tests/test_config_pfc.py +++ b/fboss/oss/cli_tests/test_config_pfc.py @@ -21,16 +21,15 @@ """ import json -import os -import shutil import sys +from typing import Any -from cli_test_lib import commit_config, run_cli - - -# Paths -SYSTEM_CONFIG_PATH = "/etc/coop/agent.conf" -SESSION_CONFIG_PATH = os.path.expanduser("~/.fboss2/agent.conf") +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" @@ -219,46 +218,20 @@ def configure_port_pfc(port_name: str, config: dict) -> None: def cleanup_test_config() -> None: """Remove PFC-related test config: portPgConfigs, bufferPoolConfigs, and port pfc.""" - 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(" Removing PFC-related configs...") - with open(SESSION_CONFIG_PATH, "r") as f: - config = json.load(f) - - sw_config = config.get("sw", {}) - # Remove global PFC configs - sw_config.pop("portPgConfigs", None) - sw_config.pop("bufferPoolConfigs", None) + 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 + # 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 - with open(SESSION_CONFIG_PATH, "w") as f: - json.dump(config, f, indent=2) - - # Update metadata to require AGENT_RESTART since we're changing PFC config - # Use symbolic names matching thrift PORTABLE format - 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() + cleanup_config(modify_config, "PFC-related configs") def main() -> int: 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..52fc615a1cfbf --- /dev/null +++ b/fboss/oss/cli_tests/test_config_portqueuecfg.py @@ -0,0 +1,252 @@ +#!/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, + 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} + +# 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", + }, +] + +# 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, + }, + ] +} + + +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"]]) + + run_cli(cmd) + + +def cleanup_test_config() -> None: + """Remove port queue config test data.""" + + 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) + + cleanup_config(modify_config, "port queue configs") + + +def main() -> int: + print("=" * 70) + print("CLI E2E Test: Port Queue 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 (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") + + # 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") + + print("\n" + "=" * 70) + print("TEST PASSED") + print("=" * 70) + + # Step 5: Cleanup test config + print("\n[Step 5] Cleaning up test config...") + cleanup_test_config() + print(" Cleanup complete") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7be0a1400ebcf66cecca67b11f50290df09f9f81 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Thu, 29 Jan 2026 01:29:59 +0000 Subject: [PATCH 12/18] Add active-queue-management (AQM) support to queuing-policy queue-id CLI # Summary This adds support for configuring Active Queue Management (AQM) attributes on port queues via the CLI. The AQM configuration includes: - congestion-behavior: `ECN` or `EARLY_DROP` - detection linear: with minimum-length, maximum-length, and probability The active-queue-management keyword must come last in the command as it consumes all remaining arguments for its nested sub-attributes. # Test Plan End-to-end test output (new test case with queue-id 4 with AQM): ``` [...] Configuring queue-id 4... [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 4 scheduling SP shared-bytes 83618832 active-queue-management congestion-behavior ECN detection linear minimum-length 120000 maximum-length 120000 probability 100 ====================================================================== TEST PASSED ====================================================================== ``` ## Sample usage ``` $ fboss2 config qos queuing-policy test_policy queue-id 5 scheduling sp \ active-queue-management congestion-behavior ecn \ detection linear minimum-length 120000 maximum-length 120000 probability 100 Successfully configured queuing-policy 'test_policy' queue-id 5 $ fboss2 config session diff + "portQueueConfigs": { + "test_policy": [ + { + "aqms": [ + { + "behavior": 1, + "detection": { + "linear": { + "maximumLength": 120000, + "minimumLength": 120000, + "probability": 100 + } + } + } + ], + "id": 5, + "scheduling": 1, + "streamType": 0 + } + ] + }, ``` --- fboss/cli/fboss2/CmdSubcommands.cpp | 2 +- .../CmdConfigQosQueuingPolicyQueueId.cpp | 187 ++++++++++++++++-- .../CmdConfigQosQueuingPolicyQueueId.h | 5 + .../oss/cli_tests/test_config_portqueuecfg.py | 55 ++++++ 4 files changed, 236 insertions(+), 13 deletions(-) diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index d73f98c9f290e..9411b286289b3 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -296,7 +296,7 @@ CLI::App* CmdSubcommands::addCommand( "Queue ID followed by key-value pairs: " "[ ...] where is one of: reserved-bytes, " "shared-bytes, weight, scaling-factor, scheduling, stream-type, " - "buffer-pool-name"); + "buffer-pool-name, active-queue-management"); break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp index c3128c4a44453..44f7660abc340 100644 --- a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp @@ -60,6 +60,15 @@ std::string getValidStreamTypes() { 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) { @@ -71,6 +80,129 @@ std::string toUpper(const std::string& value) { 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 @@ -105,7 +237,8 @@ QueueConfig::QueueConfig(std::vector v) { 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"); + "reserved-bytes, shared-bytes, weight, scaling-factor, scheduling, stream-type, " + "buffer-pool-name, active-queue-management"); } // Parse the queue ID (first argument) @@ -121,17 +254,33 @@ QueueConfig::QueueConfig(std::vector v) { } data_.push_back(v[0]); - // Parse the remaining key-value pairs - // After the queue 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 queue ID."); - } + // 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 + } - 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]); + // 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; } } @@ -224,8 +373,22 @@ CmdConfigQosQueuingPolicyQueueId::queryClient( throw std::invalid_argument( "Unknown attribute: '" + attr + "'. Valid attributes are: reserved-bytes, shared-bytes, weight, " - "scaling-factor, scheduling, stream-type, buffer-pool-name"); + "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 diff --git a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h index d532ba7bdb3a5..6b26ff3bc09e1 100644 --- a/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h +++ b/fboss/cli/fboss2/commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.h @@ -44,12 +44,17 @@ class QueueConfig : public utils::BaseObjectArgType { 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 { diff --git a/fboss/oss/cli_tests/test_config_portqueuecfg.py b/fboss/oss/cli_tests/test_config_portqueuecfg.py index 52fc615a1cfbf..09b96a1c21452 100644 --- a/fboss/oss/cli_tests/test_config_portqueuecfg.py +++ b/fboss/oss/cli_tests/test_config_portqueuecfg.py @@ -44,6 +44,9 @@ # 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, @@ -78,6 +81,21 @@ "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) @@ -112,6 +130,24 @@ "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, + } + }, + } + ], + }, ] } @@ -155,6 +191,25 @@ def configure_queue(policy_name: str, queue_id: int, config: dict) -> None: 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) From 28c0edc75a54775b7ea1f50f34e78cdc3abd119f Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Thu, 29 Jan 2026 02:46:45 +0000 Subject: [PATCH 13/18] Add config interface queuing-policy CLI command # Summary This adds a new CLI command to assign a queuing policy (port queue configuration) to a physical interface: ``` fboss2 config interface queuing-policy ``` The command validates that the specified policy exists in `portQueueConfigs` before setting the `portQueueConfigName` attribute on the port. # Test Plan End-to-end test output (expanded to include interface assignment): ``` [Step 2] Configuring queuing policy 'cli_e2e_test_queue_policy'... Configuring queue-id 2... [CLI] Running: config qos queuing-policy cli_e2e_test_queue_policy queue-id 2 scheduling SP weight 10 shared-bytes 83618832 [...] All queues configured Assigning queuing policy to interface 'eth1/1/1'... [CLI] Running: config interface eth1/1/1 queuing-policy cli_e2e_test_queue_policy [CLI] Completed in 0.07s: config interface eth1/1/1 queuing-policy cli_e2e_test_queue_policy Queuing policy assigned [Step 3] Committing configuration... [CLI] Running: config session commit [CLI] Completed in 16.09s: config session commit Configuration committed successfully [Step 4] Verifying configuration... Buffer pool 'cli_e2e_test_buffer_pool' verified Queuing policy 'cli_e2e_test_queue_policy' verified Interface 'eth1/1/1' queuing policy verified ====================================================================== TEST PASSED ====================================================================== ``` ## Sample usage ``` $ fboss2 config qos queuing-policy sample_policy queue-id 0 scheduling sp Successfully configured queuing-policy 'sample_policy' queue-id 0 $ fboss2 config interface eth1/1/1 queuing-policy sample_policy Successfully set queuing-policy 'sample_policy' for interface(s) eth1/1/1 $ fboss2 config session diff --- current live config +++ session config @@ -2197,7 +2197,15 @@ "maxNeighborProbes": 300, "mirrorOnDropReports": [], "mirrors": [], - "portQueueConfigs": {}, + "portQueueConfigs": { + "sample_policy": [ + { + "id": 0, + "scheduling": 1, + "streamType": 0 + } + ] + }, "ports": [ { "conditionalEntropyRehash": false, @@ -2219,6 +2227,7 @@ "rx": false, "tx": false }, + "portQueueConfigName": "sample_policy", "portType": 0, "profileID": 39, "queues_DEPRECATED": [], ``` --- cmake/CliFboss2.cmake | 2 + fboss/cli/fboss2/BUCK | 2 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 4 + fboss/cli/fboss2/CmdListConfig.cpp | 7 ++ .../CmdConfigInterfaceQueuingPolicy.cpp | 77 +++++++++++++++++++ .../CmdConfigInterfaceQueuingPolicy.h | 47 +++++++++++ .../oss/cli_tests/test_config_portqueuecfg.py | 44 ++++++++++- 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp create mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index e5ccd5404e933..2fc943476fc69 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -597,6 +597,8 @@ add_library(fboss2_config_lib fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h + 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 diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 129f24498d3b0..00fe086013d42 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -803,6 +803,7 @@ cpp_library( "commands/config/history/CmdConfigHistory.cpp", "commands/config/interface/CmdConfigInterfaceDescription.cpp", "commands/config/interface/CmdConfigInterfaceMtu.cpp", + "commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp", "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", @@ -825,6 +826,7 @@ cpp_library( "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", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index de58702d05292..636ab7284091b 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -21,6 +21,7 @@ #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" @@ -53,6 +54,9 @@ template void CmdHandler< CmdConfigInterfaceDescriptionTraits>::run(); template void CmdHandler::run(); +template void CmdHandler< + CmdConfigInterfaceQueuingPolicy, + CmdConfigInterfaceQueuingPolicyTraits>::run(); template void CmdHandler< CmdConfigInterfacePfcConfig, CmdConfigInterfacePfcConfigTraits>::run(); diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 2c04dd82f606d..519a49d133058 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -17,6 +17,7 @@ #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" @@ -76,6 +77,12 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, }, + { + "queuing-policy", + "Set queuing policy for interface", + commandHandler, + argTypeHandler, + }, { "switchport", "Configure switchport settings", 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..22d2ac6f86354 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2004-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h" + +#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/InterfaceList.h" + +namespace facebook::fboss { + +CmdConfigInterfaceQueuingPolicyTraits::RetType +CmdConfigInterfaceQueuingPolicy::queryClient( + const HostInfo& /* hostInfo */, + const utils::InterfaceList& interfaces, + const ObjectArgType& policyNameArg) { + 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; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h new file mode 100644 index 0000000000000..8ae9a4e061dd4 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.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 +#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/CmdUtilsCommon.h" +#include "fboss/cli/fboss2/utils/HostInfo.h" +#include "fboss/cli/fboss2/utils/InterfaceList.h" + +namespace facebook::fboss { + +struct CmdConfigInterfaceQueuingPolicyTraits : public WriteCommandTraits { + using ParentCmd = CmdConfigInterface; + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; + using ObjectArgType = utils::Message; + using RetType = std::string; +}; + +class CmdConfigInterfaceQueuingPolicy + : public CmdHandler< + CmdConfigInterfaceQueuingPolicy, + CmdConfigInterfaceQueuingPolicyTraits> { + public: + using ObjectArgType = CmdConfigInterfaceQueuingPolicyTraits::ObjectArgType; + using RetType = CmdConfigInterfaceQueuingPolicyTraits::RetType; + + RetType queryClient( + const HostInfo& hostInfo, + const utils::InterfaceList& interfaces, + const ObjectArgType& policyName); + + void printOutput(const RetType& logMsg); +}; + +} // namespace facebook::fboss diff --git a/fboss/oss/cli_tests/test_config_portqueuecfg.py b/fboss/oss/cli_tests/test_config_portqueuecfg.py index 09b96a1c21452..94feeb42ff6a8 100644 --- a/fboss/oss/cli_tests/test_config_portqueuecfg.py +++ b/fboss/oss/cli_tests/test_config_portqueuecfg.py @@ -28,6 +28,7 @@ SYSTEM_CONFIG_PATH, cleanup_config, commit_config, + find_first_eth_interface, run_cli, ) @@ -213,8 +214,12 @@ def configure_queue(policy_name: str, queue_id: int, config: dict) -> None: run_cli(cmd) -def cleanup_test_config() -> None: - """Remove port queue config test data.""" +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 @@ -222,6 +227,11 @@ def modify_config(sw_config: dict[str, Any]) -> None: 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") @@ -231,9 +241,13 @@ def main() -> int: 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() + cleanup_test_config(test_intf.name) print(" Cleanup complete") # Step 1: Configure buffer pool (needed for buffer-pool-name reference) @@ -248,6 +262,10 @@ def main() -> int: 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...") @@ -291,13 +309,31 @@ def main() -> int: 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() + cleanup_test_config(test_intf.name) print(" Cleanup complete") return 0 From b040a7013b37b85fd033d2c7a2179c5a8ed68b67 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Thu, 29 Jan 2026 23:07:39 +0000 Subject: [PATCH 14/18] Add QoS policy map configuration commands # Summary Command syntax: ``` fboss2-dev config qos policy map ``` Where: - `` is the QoS policy name - `` is one of: `tc-to-queue`, `pfc-pri-to-queue`, `tc-to-pg`, `pfc-pri-to-pg` - `` and `` are integers from 0-7 # Test Plan New end to end test: ``` ====================================================================== CLI E2E Test: QoS Policy Configuration ====================================================================== [Step 0] Cleaning up any existing test config... Copying system config to session config... Removing QoS policy test configs... Updating metadata for AGENT_RESTART... Committing cleanup... Using CLI from FBOSS_CLI_PATH: ./fboss2-dev [CLI] Running: config session commit [CLI] Completed in 2.77s: config session commit Cleanup complete [Step 1] Configuring QoS policy 'cli_e2e_test_qos_policy'... Configuring trafficClassToQueueId (tc-to-queue)... [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 0 0 [CLI] Completed in 0.08s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 0 0 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 1 1 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 1 1 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 2 2 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 2 2 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 3 3 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 3 3 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 4 4 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 4 4 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 5 5 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 5 5 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 6 6 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 6 6 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-queue 7 7 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-queue 7 7 Configuring pfcPriorityToQueueId (pfc-pri-to-queue)... [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 0 0 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 0 0 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 1 1 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 1 1 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 2 2 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 2 2 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 3 3 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 3 3 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 4 4 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 4 4 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 5 5 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 5 5 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 6 6 [CLI] Completed in 0.05s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 6 6 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 7 7 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-queue 7 7 Configuring trafficClassToPgId (tc-to-pg)... [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 0 0 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 0 0 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 1 1 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 1 1 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 2 2 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 2 2 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 3 3 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 3 3 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 4 4 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 4 4 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 5 5 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 5 5 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 6 6 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 6 6 [CLI] Running: config qos policy cli_e2e_test_qos_policy map tc-to-pg 7 7 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map tc-to-pg 7 7 Configuring pfcPriorityToPgId (pfc-pri-to-pg)... [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 0 0 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 0 0 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 1 1 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 1 1 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 2 2 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 2 2 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 3 3 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 3 3 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 4 4 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 4 4 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 5 5 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 5 5 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 6 6 [CLI] Completed in 0.06s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 6 6 [CLI] Running: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 7 7 [CLI] Completed in 0.07s: config qos policy cli_e2e_test_qos_policy map pfc-pri-to-pg 7 7 QoS policy configured [Step 2] Committing configuration... [CLI] Running: config session commit [CLI] Completed in 10.59s: config session commit Configuration committed successfully [Step 3] Verifying configuration... QoS policy 'cli_e2e_test_qos_policy' found trafficClassToQueueId verified pfcPriorityToQueueId verified trafficClassToPgId verified pfcPriorityToPgId verified ====================================================================== TEST PASSED ====================================================================== [Step 4] Cleaning up test config... Copying system config to session config... Removing QoS policy test configs... Updating metadata for AGENT_RESTART... Committing cleanup... [CLI] Running: config session commit [CLI] Completed in 6.50s: config session commit Cleanup complete ``` ## Sample usage ``` admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map tc-to-queue 0 0 Successfully set QoS policy 'test-policy' trafficClassToQueueId [0] = 0 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map pfc-pri-to-queue 3 5 Successfully set QoS policy 'test-policy' pfcPriorityToQueueId [3] = 5 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map tc-to-pg 2 2 Successfully set QoS policy 'test-policy' trafficClassToPgId [2] = 2 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map pfc-pri-to-pg 7 7 Successfully set QoS policy 'test-policy' pfcPriorityToPgId [7] = 7 admin@fboss101:~/benoit$ ./fboss2-dev config session diff --- current live config +++ session config @@ -5871,7 +5871,28 @@ } ], "proactiveArp": false, - "qosPolicies": [], + "qosPolicies": [ + { + "name": "test-policy", + "qosMap": { + "dscpMaps": [], + "expMaps": [], + "pfcPriorityToPgId": { + "7": 7 + }, + "pfcPriorityToQueueId": { + "3": 5 + }, + "trafficClassToPgId": { + "2": 2 + }, + "trafficClassToQueueId": { + "0": 0 + } + }, + "rules": [] + } + ], "sFlowCollectors": [], "sdkVersion": { "asicSdk": "sdk", ``` --- cmake/CliFboss2.cmake | 3 + fboss/cli/fboss2/BUCK | 3 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 5 + fboss/cli/fboss2/CmdListConfig.cpp | 14 ++ fboss/cli/fboss2/CmdSubcommands.cpp | 10 + .../config/qos/policy/CmdConfigQosPolicy.h | 65 ++++++ .../qos/policy/CmdConfigQosPolicyMap.cpp | 181 ++++++++++++++++ .../config/qos/policy/CmdConfigQosPolicyMap.h | 91 ++++++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 3 + fboss/oss/cli_tests/test_config_qos.py | 194 ++++++++++++++++++ 10 files changed, 569 insertions(+) create mode 100644 fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicy.h create mode 100644 fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp create mode 100644 fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h create mode 100644 fboss/oss/cli_tests/test_config_qos.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 2fc943476fc69..dc94f4727da2d 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -609,6 +609,9 @@ add_library(fboss2_config_lib 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.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.h fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp fboss/cli/fboss2/commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.h diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 00fe086013d42..c2220baa3b057 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -807,6 +807,7 @@ cpp_library( "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", + "commands/config/qos/policy/CmdConfigQosPolicyMap.cpp", "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", "commands/config/qos/queuing_policy/CmdConfigQosQueuingPolicyQueueId.cpp", "commands/config/rollback/CmdConfigRollback.cpp", @@ -834,6 +835,8 @@ cpp_library( "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 636ab7284091b..707618fdda9cd 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -28,6 +28,8 @@ #include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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" @@ -80,6 +82,9 @@ 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(); diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 519a49d133058..8441c0d7f1dee 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -24,6 +24,8 @@ #include "fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.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" @@ -116,6 +118,18 @@ const CommandTree& kConfigCommandTree() { 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", diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 9411b286289b3..cb123946155c6 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -298,6 +298,16 @@ CLI::App* CmdSubcommands::addCommand( "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"); + break; case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..e0c48971bf6cc --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp @@ -0,0 +1,181 @@ +/* + * 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 +#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 kMinValue = 0; +constexpr int16_t kMaxValue = 7; + +std::string getMapTypeString(QosMapType mapType) { + 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"; + } + folly::assume_unreachable(); +} + +} // namespace + +QosMapConfig::QosMapConfig(std::vector v) { + // Expected format: + if (v.size() < 3) { + 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"); + } + + // Parse the map type + const auto& mapTypeStr = v[0]; + if (mapTypeStr == "tc-to-queue") { + mapType_ = QosMapType::TC_TO_QUEUE; + } else if (mapTypeStr == "pfc-pri-to-queue") { + mapType_ = QosMapType::PFC_PRI_TO_QUEUE; + } else if (mapTypeStr == "tc-to-pg") { + mapType_ = QosMapType::TC_TO_PG; + } else if (mapTypeStr == "pfc-pri-to-pg") { + mapType_ = QosMapType::PFC_PRI_TO_PG; + } 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", + mapTypeStr)); + } + data_.push_back(mapTypeStr); + + // Parse the key + key_ = folly::to(v[1]); + if (key_ < kMinValue || key_ > kMaxValue) { + throw std::invalid_argument( + fmt::format( + "Key must be between {} and {}, got: {}", + kMinValue, + kMaxValue, + key_)); + } + data_.push_back(v[1]); + + // Parse the value + value_ = folly::to(v[2]); + if (value_ < kMinValue || value_ > kMaxValue) { + throw std::invalid_argument( + fmt::format( + "Value must be between {} and {}, got: {}", + kMinValue, + kMaxValue, + value_)); + } + data_.push_back(v[2]); +} + +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 + QosMapType mapType = config.getMapType(); + int16_t key = config.getKey(); + int16_t value = config.getValue(); + + switch (mapType) { + 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; + } + + session.saveConfig(); + + return fmt::format( + "Successfully set QoS policy '{}' {} [{}] = {}", + name, + getMapTypeString(mapType), + key, + value); +} + +void CmdConfigQosPolicyMap::printOutput(const RetType& logMsg) { + std::cout << logMsg << std::endl; +} + +} // 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..ac8fe3f27d63e --- /dev/null +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h @@ -0,0 +1,91 @@ +/* + * 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 +}; + +/** + * Custom type for QoS map entry configuration. + * + * Parses command line arguments in the format: + * + * + * For example: + * tc-to-queue 0 0 + * pfc-pri-to-queue 3 3 + */ +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_; + } + + const static utils::ObjectArgTypeId id = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_QOS_MAP_ENTRY; + + private: + QosMapType mapType_{QosMapType::TC_TO_QUEUE}; + int16_t key_{0}; + int16_t value_{0}; +}; + +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/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 45f0bc84b6864..171653cbf5cca 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -85,6 +85,9 @@ enum class ObjectArgTypeId : uint8_t { // 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, }; template 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()) From de18a53ee35468eb63655ff640aad9be76f370e9 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Tue, 3 Feb 2026 01:58:36 +0000 Subject: [PATCH 15/18] Rewrite fboss2-dev CLI end-to-end tests in C++ with gtest # Summary This change enables running multiple CLI commands within a single process, which is required for C++ end-to-end tests. The first two end-to-end tests that were originally written in Python, along with helper functions, have been rewritten in C++, and can now run as a self-contained gtest binary, without depending on the fboss2-dev binary. Some fixes were necessary to make this work, so the CLI could be re-used as a library instead of being invoked as a subprocess: 1. Remove the `hasRun` static variable from `CmdHandler` - Previously, a static `hasRun` flag prevented commands from executing more than once per process - Now we wrap callbacks in `CmdSubcommands` to check `get_subcommands().empty()` to detect leaf commands, eliminating the need for static state 2. Throw exceptions instead of calling `exit(1)` in `CmdHandler` - `CmdHandler::run()` now rethrows exceptions instead of calling `exit(1)` - This allows tests to catch errors and verify exit codes - `Main.cpp` catches exceptions and returns exit code 1 3. Factor out `CLI::App` initialization into `CliAppInit.h` - Header-only `initApp()` function shared between `Main.cpp` and tests - Eliminates code duplication for CLI setup - This couldn't be packaged into a library of its own without going into circular dependency hell, hence why it's a header-only helper 4. Reset state in `ConfigSession::initializeSession()` - Clear `ConfigSession` attributes when creating a new session (when session file doesn't exist) - Allows re-using the same `ConfigSession` singleton after `commit()` 5. Update state in `ConfigSession::commit()` - Set `base_` to the new commit SHA after successful commit - Set `configLoaded_ = false` to force config reload on next access - Allows re-using the same `ConfigSession` singleton after `commit()` 6. Automatically initialize session in `ConfigSession::loadConfig()` - If session file doesn't exist (e.g., after commit deleted it), call `initializeSession()` to create a fresh session These changes allow the ConfigSession singleton to be properly reused across multiple CLI command invocations within the same process. # Test Plan Added new end-to-end tests in C++ that replace `test_config_interface_mtu.py` and `test_config_interface_description.py` (the Python scripts will be removed in a future PR soon). Sample output (simplified, without timestamps and other noise): ``` [==========] Running 2 tests from 2 test suites. [----------] 1 test from ConfigInterfaceDescriptionTest [ RUN ] ConfigInterfaceDescriptionTest.SetAndVerifyDescription [Step 1] Finding an interface to test... Using interface: eth1/1/1 (VLAN: 2001) [Step 2] Getting current description... Current description: 'CLI_E2E_TEST_DESCRIPTION_ALT' [Step 3] Setting description to 'CLI_E2E_TEST_DESCRIPTION'... Description set to 'CLI_E2E_TEST_DESCRIPTION' [Step 4] Verifying description via 'show interface'... Verified: Description is 'CLI_E2E_TEST_DESCRIPTION' [Step 5] Restoring original description ('CLI_E2E_TEST_DESCRIPTION_ALT')... Restored description to 'CLI_E2E_TEST_DESCRIPTION_ALT' TEST PASSED [ OK ] ConfigInterfaceDescriptionTest.SetAndVerifyDescription (412 ms) [----------] 1 test from ConfigInterfaceMtuTest [ RUN ] ConfigInterfaceMtuTest.SetAndVerifyMtu [Step 1] Finding an interface to test... Using interface: eth1/1/1 (VLAN: 2001) [Step 2] Getting current MTU... Current MTU: 9000 [Step 3] Setting MTU to 1500... MTU set to 1500 [Step 4] Verifying MTU via 'show interface'... Verified: MTU is 1500 [Step 5] Verifying kernel interface MTU... Verified: Kernel interface fboss2001 has MTU 1500 [Step 6] Restoring original MTU (9000)... Restored MTU to 9000 TEST PASSED [ OK ] ConfigInterfaceMtuTest.SetAndVerifyMtu (384 ms) [==========] 2 tests from 2 test suites ran. (797 ms total) [ PASSED ] 2 tests. ``` --- cmake/CliFboss2.cmake | 1 + cmake/CliFboss2Test.cmake | 30 ++ fboss/cli/fboss2/BUCK | 1 + fboss/cli/fboss2/CliAppInit.h | 37 +++ fboss/cli/fboss2/CmdHandler.cpp | 49 +++- fboss/cli/fboss2/CmdSubcommands.cpp | 10 +- fboss/cli/fboss2/Main.cpp | 31 +- fboss/cli/fboss2/session/ConfigSession.cpp | 24 +- fboss/cli/test/BUCK | 40 +++ fboss/cli/test/CliTest.cpp | 274 ++++++++++++++++++ fboss/cli/test/CliTest.h | 133 +++++++++ .../test/ConfigInterfaceDescriptionTest.cpp | 85 ++++++ fboss/cli/test/ConfigInterfaceMtuTest.cpp | 83 ++++++ fboss/oss/scripts/run_scripts/run_test.py | 198 ++++++------- 14 files changed, 858 insertions(+), 138 deletions(-) create mode 100644 fboss/cli/fboss2/CliAppInit.h create mode 100644 fboss/cli/test/BUCK create mode 100644 fboss/cli/test/CliTest.cpp create mode 100644 fboss/cli/test/CliTest.h create mode 100644 fboss/cli/test/ConfigInterfaceDescriptionTest.cpp create mode 100644 fboss/cli/test/ConfigInterfaceMtuTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index dc94f4727da2d..7ae802d30631e 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -485,6 +485,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 diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index b28b3f6269836..6ad2bc9d01766 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -112,3 +112,33 @@ 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 +) + +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 c2220baa3b057..90a11acae9166 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", ], 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/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index cb123946155c6..6bcb7808d1be3 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -70,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 = 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/session/ConfigSession.cpp b/fboss/cli/fboss2/session/ConfigSession.cpp index b0fc1cb7b8d18..6a33f454ca0c4 100644 --- a/fboss/cli/fboss2/session/ConfigSession.cpp +++ b/fboss/cli/fboss2/session/ConfigSession.cpp @@ -402,9 +402,10 @@ void ConfigSession::saveConfig( // Automatically record the command from /proc/self/cmdline. // This ensures all config commands are tracked without requiring manual // instrumentation in each command implementation. + // Note: When running CLI commands directly (e.g., in tests), + // /proc/self/cmdline may not contain the CLI command, so we gracefully skip + // command tracking. std::string rawCmd = readCommandLineFromProc(); - CHECK(!rawCmd.empty()) - << "saveConfig() called with no command line arguments"; // Only record if this is a config command and not already the last one // recorded as that'd be idempotent anyway. Strip any leading flags. auto pos = rawCmd.find("config "); @@ -604,6 +605,12 @@ void ConfigSession::restartAgent(cli::AgentType agent) { } 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; std::string sessionConfigPath = getSessionConfigPath(); if (!folly::readFile(sessionConfigPath.c_str(), configJson)) { @@ -626,6 +633,13 @@ void ConfigSession::loadConfig() { void ConfigSession::initializeSession() { initializeGit(); if (!sessionExists()) { + // 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(); @@ -817,8 +831,12 @@ ConfigSession::CommitResult ConfigSession::commit(const HostInfo& hostInfo) { ec.message()); } - // Reset action level after successful commit + // Reset internal state after successful commit so subsequent commands + // start with a fresh session based on the new commit. resetRequiredAction(cli::AgentType::WEDGE_AGENT); + base_ = commitSha; + // Force config reload from system config on next access + configLoaded_ = false; return CommitResult{commitSha, actionLevel}; } diff --git a/fboss/cli/test/BUCK b/fboss/cli/test/BUCK new file mode 100644 index 0000000000000..c828bef74a1e3 --- /dev/null +++ b/fboss/cli/test/BUCK @@ -0,0 +1,40 @@ +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", + ], + 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/oss/scripts/run_scripts/run_test.py b/fboss/oss/scripts/run_scripts/run_test.py index ebf603bab8f7b..2f3294e435bb6 100755 --- a/fboss/oss/scripts/run_scripts/run_test.py +++ b/fboss/oss/scripts/run_scripts/run_test.py @@ -1291,116 +1291,115 @@ def _filter_tests(self, tests: List[str]) -> List[str]: return tests_to_run -class CliTestRunner: +class CliTestRunner(TestRunner): """ 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) @@ -1606,6 +1605,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() From 05ea674a26b11250814747378b0c444355f6d59c Mon Sep 17 00:00:00 2001 From: Manoharan Sundaramoorthy Date: Wed, 4 Feb 2026 03:43:05 +0530 Subject: [PATCH 16/18] Add CLI commands: config vlan port taggingMode and config l2 learning-mode # Summary Implements two new fboss2-dev CLI commands for switch configuration. ## 1. config vlan port taggingMode Configures the VLAN tagging mode for a port within a VLAN. This controls whether packets are tagged or untagged when egressing the port. Where `` can be: - `tagged` - Packets egress with VLAN tag - `untagged` - Packets egress without VLAN tag This is a hitless configuration change (no agent restart required). ## 2. config l2 learning-mode Configures the L2 learning mode (`l2LearningMode` in `SwitchSettings`). This controls how the switch learns MAC addresses. Where `` can be: - `hardware` - MAC learning is performed by the hardware ASIC - `software` - MAC learning is performed by the software agent - `disabled` - MAC learning is disabled Note: Changing L2 learning mode requires an agent restart (not hitless) because the FBOSS agent does not permit changing this setting after initial config application. # Test Plan ## Unit tests **config vlan port taggingMode** (15 test cases): - Argument validation, mode conversion, error handling **config l2 learning-mode** (14 test cases): - Argument validation, mode conversion, error handling ## End-to-end tests **config vlan port taggingMode:** ``` ============================================================ CLI E2E Test: config vlan port taggingMode ============================================================ [Step 1] Finding an interface to test... Using CLI from FBOSS_CLI_PATH: /home/admin/manoharan/fboss2-dev [CLI] Running: show interface [CLI] Completed in 0.08s: show interface Using interface: eth1/1/1 (VLAN: 2001) [Step 2] Getting current tagging mode... [CLI] Running: show running-config [CLI] Completed in 0.07s: show running-config Current mode: untagged [Step 3] Setting tagging mode to 'tagged'... [CLI] Running: config vlan 2001 port eth1/1/1 taggingMode tagged [CLI] Completed in 0.08s: config vlan 2001 port eth1/1/1 taggingMode tagged [CLI] Running: config session commit [CLI] Completed in 0.16s: config session commit Tagging mode set to 'tagged' [Step 4] Verifying tagging mode is 'tagged'... [CLI] Running: show running-config [CLI] Completed in 0.06s: show running-config Verified: Tagging mode is 'tagged' [Step 5] Setting tagging mode to 'untagged'... [CLI] Running: config vlan 2001 port eth1/1/1 taggingMode untagged [CLI] Completed in 0.08s: config vlan 2001 port eth1/1/1 taggingMode untagged [CLI] Running: config session commit [CLI] Completed in 0.15s: config session commit Tagging mode set to 'untagged' [Step 6] Verifying tagging mode is 'untagged'... [CLI] Running: show running-config [CLI] Completed in 0.06s: show running-config Verified: Tagging mode is 'untagged' ============================================================ TEST PASSED ============================================================ ``` **config l2 learning-mode:** ``` ============================================================ CLI E2E Test: config l2 learning-mode ============================================================ [Step 1] Getting current L2 learning mode... Using CLI from FBOSS_CLI_PATH: /home/admin/manoharan/fboss2-dev [CLI] Running: show running-config [CLI] Completed in 0.06s: show running-config Current mode: hardware [Step 2] Setting L2 learning mode to 'software'... [CLI] Running: config l2 learning-mode software [CLI] Completed in 0.07s: config l2 learning-mode software [CLI] Running: config session commit [CLI] Completed in 2.88s: config session commit Waiting for agent to be ready after restart... Sleeping 30s for agent restart... L2 learning mode set to 'software' [Step 3] Verifying L2 learning mode is 'software'... [CLI] Running: show running-config [CLI] Completed in 0.06s: show running-config Verified: L2 learning mode is 'software' [Step 4] Setting L2 learning mode to 'hardware'... [CLI] Running: config l2 learning-mode hardware [CLI] Completed in 0.08s: config l2 learning-mode hardware [CLI] Running: config session commit [CLI] Completed in 3.51s: config session commit Waiting for agent to be ready after restart... Sleeping 30s for agent restart... L2 learning mode set to 'hardware' [Step 5] Verifying L2 learning mode is 'hardware'... [CLI] Running: show running-config [CLI] Completed in 0.06s: show running-config Verified: L2 learning mode is 'hardware' ============================================================ TEST PASSED ============================================================ ``` ## Sample usage ``` [admin@fboss101 ~]$ fboss2-dev config vlan 2001 port eth1/1/1 taggingMode untagged Successfully set tagging mode for port 'eth1/1/1' in VLAN 2001 to 'untagged' [admin@fboss101 ~]$ fboss2-dev config l2 learning-mode software Successfully set L2 learning mode to 'software' [admin@fboss101 ~]$ fboss2-dev config session commit wedge_agent restarted, config committed ``` --- cmake/CliFboss2.cmake | 6 + cmake/CliFboss2Test.cmake | 2 + fboss/cli/fboss2/BUCK | 6 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 11 + fboss/cli/fboss2/CmdListConfig.cpp | 62 +++- fboss/cli/fboss2/CmdSubcommands.cpp | 12 + .../fboss2/commands/config/l2/CmdConfigL2.h | 37 ++ .../learning_mode/CmdConfigL2LearningMode.cpp | 95 ++++++ .../learning_mode/CmdConfigL2LearningMode.h | 58 ++++ .../config/vlan/port/CmdConfigVlanPort.h | 43 +++ .../CmdConfigVlanPortTaggingMode.cpp | 110 ++++++ .../CmdConfigVlanPortTaggingMode.h | 83 +++++ fboss/cli/fboss2/test/BUCK | 2 + .../test/CmdConfigL2LearningModeTest.cpp | 209 ++++++++++++ .../test/CmdConfigVlanPortTaggingModeTest.cpp | 323 ++++++++++++++++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 2 + fboss/oss/cli_tests/cli_test_lib.py | 58 ++++ .../cli_tests/test_config_l2_learning_mode.py | 135 ++++++++ .../test_config_vlan_port_tagging_mode.py | 139 ++++++++ 19 files changed, 1377 insertions(+), 16 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/l2/CmdConfigL2.h create mode 100644 fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp create mode 100644 fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.h create mode 100644 fboss/cli/fboss2/commands/config/vlan/port/CmdConfigVlanPort.h create mode 100644 fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp create mode 100644 fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.h create mode 100644 fboss/cli/fboss2/test/CmdConfigL2LearningModeTest.cpp create mode 100644 fboss/cli/fboss2/test/CmdConfigVlanPortTaggingModeTest.cpp create mode 100644 fboss/oss/cli_tests/test_config_l2_learning_mode.py create mode 100644 fboss/oss/cli_tests/test_config_vlan_port_tagging_mode.py diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index 7ae802d30631e..ba79e4fc87afd 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -607,6 +607,9 @@ add_library(fboss2_config_lib 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.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.h fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp fboss/cli/fboss2/commands/config/qos/buffer_pool/CmdConfigQosBufferPool.h @@ -630,6 +633,9 @@ add_library(fboss2_config_lib fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.h fboss/cli/fboss2/commands/config/session/CmdConfigSessionRebase.cpp fboss/cli/fboss2/commands/config/vlan/CmdConfigVlan.h + 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.h fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.h fboss/cli/fboss2/commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index 6ad2bc9d01766..c4376cbe4f05f 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -40,10 +40,12 @@ add_executable(fboss2_cmd_test 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/CmdConfigQosBufferPoolTest.cpp fboss/cli/fboss2/test/CmdConfigReloadTest.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 diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 90a11acae9166..856d585dd5902 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -807,6 +807,7 @@ cpp_library( "commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp", "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", + "commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", "commands/config/qos/policy/CmdConfigQosPolicyMap.cpp", "commands/config/qos/priority_group_policy/CmdConfigQosPriorityGroupPolicyGroupId.cpp", @@ -815,6 +816,7 @@ cpp_library( "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", "commands/config/session/CmdConfigSessionRebase.cpp", + "commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp", "commands/config/vlan/static_mac/add/CmdConfigVlanStaticMacAdd.cpp", "commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp", "session/ConfigSession.cpp", @@ -834,6 +836,8 @@ cpp_library( "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", @@ -847,6 +851,8 @@ cpp_library( "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", diff --git a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 707618fdda9cd..00911340d61a1 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -26,6 +26,8 @@ #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" @@ -39,6 +41,8 @@ #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" @@ -79,6 +83,9 @@ template void CmdHandler::run(); template void CmdHandler::run(); +template void CmdHandler::run(); +template void +CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); @@ -86,6 +93,10 @@ template void CmdHandler::run(); template void CmdHandler::run(); template void CmdHandler::run(); +template void CmdHandler::run(); +template void CmdHandler< + CmdConfigVlanPortTaggingMode, + CmdConfigVlanPortTaggingModeTraits>::run(); template void CmdHandler::run(); template void diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 8441c0d7f1dee..5d17a28f566e6 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -22,6 +22,8 @@ #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" @@ -35,6 +37,8 @@ #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" @@ -106,6 +110,20 @@ const CommandTree& kConfigCommandTree() { }}, }, + { + "config", + "l2", + "Configure L2 settings", + commandHandler, + argTypeHandler, + {{ + "learning-mode", + "Set L2 learning mode (hardware, software, or disabled)", + commandHandler, + argTypeHandler, + }}, + }, + { "config", "qos", @@ -197,23 +215,35 @@ const CommandTree& kConfigCommandTree() { 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, + "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()); diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 6bcb7808d1be3..78648e94e5ea1 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -316,6 +316,18 @@ CLI::App* CmdSubcommands::addCommand( " where map-type is one of: " "tc-to-queue, pfc-pri-to-queue, tc-to-pg, pfc-pri-to-pg"); 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_UNINITIALIZE: case utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE: break; 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..6163ed2f2dae0 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp @@ -0,0 +1,95 @@ +/* + * 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 +#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; +} + +} // 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/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..eb08a76b0e280 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/vlan/port/tagging_mode/CmdConfigVlanPortTaggingMode.cpp @@ -0,0 +1,110 @@ +/* + * 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 +#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; +} + +} // 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/test/BUCK b/fboss/cli/fboss2/test/BUCK index 7fda701a7351f..5937553d371a9 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -68,10 +68,12 @@ cpp_unittest( "CmdConfigInterfaceDescriptionTest.cpp", "CmdConfigInterfaceMtuTest.cpp", "CmdConfigInterfaceSwitchportAccessVlanTest.cpp", + "CmdConfigL2LearningModeTest.cpp", "CmdConfigQosBufferPoolTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", + "CmdConfigVlanPortTaggingModeTest.cpp", "CmdConfigVlanStaticMacTest.cpp", "CmdGetPcapTest.cpp", "CmdListConfigTest.cpp", 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/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/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index 171653cbf5cca..f779278a945c7 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -88,6 +88,8 @@ enum class ObjectArgTypeId : uint8_t { // 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, }; template diff --git a/fboss/oss/cli_tests/cli_test_lib.py b/fboss/oss/cli_tests/cli_test_lib.py index 6e9c0db3c12a2..51e76537ac111 100644 --- a/fboss/oss/cli_tests/cli_test_lib.py +++ b/fboss/oss/cli_tests/cli_test_lib.py @@ -297,3 +297,61 @@ def cleanup_config( 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_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_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()) From bbff78aa538585369da5dfac57f9c36bda9175fb Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Thu, 5 Feb 2026 01:09:31 +0000 Subject: [PATCH 17/18] Add dscp, exp, and dot1p map configuration commands # Summary Add CLI commands to configure the dscpMaps, expMaps, and pcpMaps attributes of the QosMap object within a QosPolicy. These commands allow configuring bidirectional mappings between codepoint values and internal traffic classes. New CLI commands: - DSCP maps (RFC 2474, values 0-63): - `config qos policy map dscp traffic-class ` - `config qos policy map traffic-class dscp ` - MPLS EXP maps (RFC 3032/5462, values 0-7): - `config qos policy map mpls-exp traffic-class ` - `config qos policy map traffic-class mpls-exp ` - DOT1P/PCP maps (802.1Q, values 0-7): - `config qos policy map dot1p traffic-class ` - `config qos policy map traffic-class dot1p ` The first command in each pair is additive (ingress classification - multiple codepoints can map to the same traffic class), while the second is not additive (egress rewrite - one traffic class maps to one codepoint value). # Test Plan New end to end tests: ``` Note: Google Test filter = *ConfigQosPolicyMapTest* [==========] Running 1 test from 1 test suite. [----------] Global test environment set-up. [----------] 1 test from ConfigQosPolicyMapTest [ RUN ] ConfigQosPolicyMapTest.CreateSamplePolicy I0204 18:36:53.719844 63663 CliTest.cpp:35] CliTest::SetUp - starting CLI test I0204 18:36:53.719979 63663 ConfigQosPolicyMapTest.cpp:63] Using test policy name: cli_e2e_test_qos_policy_1770259013719 I0204 18:36:53.719994 63663 ConfigQosPolicyMapTest.cpp:229] ======================================== I0204 18:36:53.719999 63663 ConfigQosPolicyMapTest.cpp:230] ConfigQosPolicyMapTest::CreateSamplePolicy I0204 18:36:53.720004 63663 ConfigQosPolicyMapTest.cpp:231] ======================================== I0204 18:36:53.720008 63663 ConfigQosPolicyMapTest.cpp:242] [Step 1] Configuring DOT1P/PCP maps... I0204 18:36:53.720016 63663 CliTest.cpp:118] Running CLI command: config qos policy cli_e2e_test_qos_policy_1770259013719 map dot1p 0 traffic-class 0 I0204 18:36:53.761480 63663 ConfigQosPolicyMapTest.cpp:102] Command: dot1p 0 traffic-class 0 I0204 18:36:53.761503 63663 ConfigQosPolicyMapTest.cpp:104] stdout: {"localhost":"Successfully set QoS policy 'cli_e2e_test_qos_policy_1770259013719' pcpMaps.fromPcpToTrafficClass [tc=0] = 0"} [... additional map configuration commands ...] I0204 18:36:53.985447 63663 ConfigQosPolicyMapTest.cpp:276] DOT1P maps configured I0204 18:36:53.985452 63663 ConfigQosPolicyMapTest.cpp:279] [Step 2] Configuring traffic-class-to-queue mappings... I0204 18:36:53.985462 63663 CliTest.cpp:118] Running CLI command: config qos policy cli_e2e_test_qos_policy_1770259013719 map tc-to-queue 0 0 I0204 18:36:54.002140 63663 ConfigQosPolicyMapTest.cpp:128] Command: tc-to-queue 0 0 I0204 18:36:54.002164 63663 ConfigQosPolicyMapTest.cpp:129] stdout: {"localhost":"Successfully set QoS policy 'cli_e2e_test_qos_policy_1770259013719' trafficClassToQueueId [0] = 0"} [... additional tc-to-queue commands ...] I0204 18:36:54.091056 63663 ConfigQosPolicyMapTest.cpp:286] TC-to-queue mappings configured I0204 18:36:54.091071 63663 ConfigQosPolicyMapTest.cpp:289] [Step 3] Committing config... I0204 18:36:54.091084 63663 CliTest.cpp:118] Running CLI command: config session commit I0204 18:36:54.316841 63663 ConfigQosPolicyMapTest.cpp:291] Config committed I0204 18:36:54.316868 63663 ConfigQosPolicyMapTest.cpp:294] [Step 4] Verifying config was applied... I0204 18:36:54.326264 63663 ConfigQosPolicyMapTest.cpp:297] Got running config from agent I0204 18:36:54.326306 63663 ConfigQosPolicyMapTest.cpp:303] Found test policy in running config I0204 18:36:54.326319 63663 ConfigQosPolicyMapTest.cpp:314] Verified pcpMaps has 6 entries I0204 18:36:54.326344 63663 ConfigQosPolicyMapTest.cpp:329] All pcpMap entries verified I0204 18:36:54.326363 63663 ConfigQosPolicyMapTest.cpp:347] trafficClassToQueueId verified I0204 18:36:54.326374 63663 ConfigQosPolicyMapTest.cpp:349] TEST PASSED [ OK ] ConfigQosPolicyMapTest.CreateSamplePolicy (607 ms) [----------] 1 test from ConfigQosPolicyMapTest (607 ms total) [----------] Global test environment tear-down [==========] 1 test from 1 test suite ran. (607 ms total) [ PASSED ] 1 test. ``` ## Sample usage ``` admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map dscp 10 traffic-class 1 Successfully set QoS policy 'test-policy' dscpMaps.fromDscpToTrafficClass [tc=1] = 10 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map dscp 20 traffic-class 1 Successfully set QoS policy 'test-policy' dscpMaps.fromDscpToTrafficClass [tc=1] = 20 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map traffic-class 1 dscp 10 Successfully set QoS policy 'test-policy' dscpMaps.fromTrafficClassToDscp [tc=1] = 10 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map mpls-exp 3 traffic-class 2 Successfully set QoS policy 'test-policy' expMaps.fromExpToTrafficClass [tc=2] = 3 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map traffic-class 2 mpls-exp 3 Successfully set QoS policy 'test-policy' expMaps.fromTrafficClassToExp [tc=2] = 3 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map dot1p 5 traffic-class 3 Successfully set QoS policy 'test-policy' pcpMaps.fromPcpToTrafficClass [tc=3] = 5 admin@fboss101:~/benoit$ ./fboss2-dev config qos policy test-policy map traffic-class 3 dot1p 5 Successfully set QoS policy 'test-policy' pcpMaps.fromTrafficClassToPcp [tc=3] = 5 admin@fboss101:~/benoit$ ./fboss2-dev config session diff --- current live config +++ session config @@ -6244,6 +6244,41 @@ } }, "rules": [] + }, + { + "name": "test-policy", + "qosMap": { + "dscpMaps": [ + { + "fromDscpToTrafficClass": [ + 10, + 20 + ], + "fromTrafficClassToDscp": 10, + "internalTrafficClass": 1 + } + ], + "expMaps": [ + { + "fromExpToTrafficClass": [ + 3 + ], + "fromTrafficClassToExp": 3, + "internalTrafficClass": 2 + } + ], + "pcpMaps": [ + { + "fromPcpToTrafficClass": [ + 5 + ], + "fromTrafficClassToPcp": 5, + "internalTrafficClass": 3 + } + ], + "trafficClassToQueueId": {} + }, + "rules": [] } ], "sFlowCollectors": [], [...] ``` --- cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/CmdSubcommands.cpp | 5 +- .../qos/policy/CmdConfigQosPolicyMap.cpp | 363 +++++++++++++++--- .../config/qos/policy/CmdConfigQosPolicyMap.h | 53 ++- fboss/cli/test/BUCK | 1 + fboss/cli/test/ConfigQosPolicyMapTest.cpp | 349 +++++++++++++++++ 6 files changed, 710 insertions(+), 62 deletions(-) create mode 100644 fboss/cli/test/ConfigQosPolicyMapTest.cpp diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index c4376cbe4f05f..d2014e5c90313 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -125,6 +125,7 @@ add_executable(cli_test fboss/cli/test/CliTest.cpp fboss/cli/test/ConfigInterfaceDescriptionTest.cpp fboss/cli/test/ConfigInterfaceMtuTest.cpp + fboss/cli/test/ConfigQosPolicyMapTest.cpp ) target_link_libraries(cli_test diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 78648e94e5ea1..431e852e328bb 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -313,8 +313,9 @@ CLI::App* CmdSubcommands::addCommand( 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"); + " ... 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( diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp index e0c48971bf6cc..f07384401f790 100644 --- a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -30,10 +31,19 @@ namespace facebook::fboss { namespace { -constexpr int16_t kMinValue = 0; -constexpr int16_t kMaxValue = 7; +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) { +std::string getMapTypeString(QosMapType mapType, QosMapDirection direction) { switch (mapType) { case QosMapType::TC_TO_QUEUE: return "trafficClassToQueueId"; @@ -43,64 +53,255 @@ std::string getMapTypeString(QosMapType mapType) { 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) { - // Expected format: - if (v.size() < 3) { + 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"); + "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"); } - // Parse the map type - const auto& mapTypeStr = v[0]; - if (mapTypeStr == "tc-to-queue") { + const auto& firstToken = v[0]; + + // Check for simple map types first + if (firstToken == "tc-to-queue") { mapType_ = QosMapType::TC_TO_QUEUE; - } else if (mapTypeStr == "pfc-pri-to-queue") { + } else if (firstToken == "pfc-pri-to-queue") { mapType_ = QosMapType::PFC_PRI_TO_QUEUE; - } else if (mapTypeStr == "tc-to-pg") { + } else if (firstToken == "tc-to-pg") { mapType_ = QosMapType::TC_TO_PG; - } else if (mapTypeStr == "pfc-pri-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", - mapTypeStr)); + "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)); } - data_.push_back(mapTypeStr); // Parse the key key_ = folly::to(v[1]); - if (key_ < kMinValue || key_ > kMaxValue) { + if (key_ < kMinTCValue || key_ > kMaxTCValue) { throw std::invalid_argument( fmt::format( "Key must be between {} and {}, got: {}", - kMinValue, - kMaxValue, + kMinTCValue, + kMaxTCValue, key_)); } data_.push_back(v[1]); // Parse the value value_ = folly::to(v[2]); - if (value_ < kMinValue || value_ > kMaxValue) { + if (value_ < kMinTCValue || value_ > kMaxTCValue) { throw std::invalid_argument( fmt::format( "Value must be between {} and {}, got: {}", - kMinValue, - kMaxValue, + 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, @@ -136,42 +337,94 @@ CmdConfigQosPolicyMapTraits::RetType CmdConfigQosPolicyMap::queryClient( auto& qosMap = *targetPolicy->qosMap(); // Set the appropriate map entry based on map type - QosMapType mapType = config.getMapType(); - int16_t key = config.getKey(); - int16_t value = config.getValue(); + 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; + } - switch (mapType) { - 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; - } + session.saveConfig(); - 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(); - return fmt::format( - "Successfully set QoS policy '{}' {} [{}] = {}", - name, - getMapTypeString(mapType), - key, - value); + 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) { diff --git a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h index ac8fe3f27d63e..fe11bad57e50a 100644 --- a/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h +++ b/fboss/cli/fboss2/commands/config/qos/policy/CmdConfigQosPolicyMap.h @@ -27,18 +27,37 @@ enum class QosMapType { TC_TO_QUEUE, // trafficClassToQueueId PFC_PRI_TO_QUEUE, // pfcPriorityToQueueId TC_TO_PG, // trafficClassToPgId - PFC_PRI_TO_PG // pfcPriorityToPgId + 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 example: - * tc-to-queue 0 0 - * pfc-pri-to-queue 3 3 + * 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: @@ -57,13 +76,37 @@ class QosMapConfig : public utils::BaseObjectArgType { 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 { diff --git a/fboss/cli/test/BUCK b/fboss/cli/test/BUCK index c828bef74a1e3..2cb21455b8838 100644 --- a/fboss/cli/test/BUCK +++ b/fboss/cli/test/BUCK @@ -20,6 +20,7 @@ cpp_binary( "CliTest.cpp", "ConfigInterfaceDescriptionTest.cpp", "ConfigInterfaceMtuTest.cpp", + "ConfigQosPolicyMapTest.cpp", ], deps = [ "fbsource//third-party/googletest:gtest", 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"; +} From 4030d7a381335de6e86d0da534eba1458e83c210 Mon Sep 17 00:00:00 2001 From: hillol-nexthop Date: Tue, 10 Feb 2026 18:53:21 +0530 Subject: [PATCH 18/18] Consolidate interface description and MTU config commands into one cpp class # Summary This PR consolidates the separate CmdConfigInterfaceDescription and CmdConfigInterfaceMtu commands into a unified CmdConfigInterface command that can set multiple interface attributes in a single invocation. ``` # Set description config interface eth1/1/1 description "My port description" # Set MTU config interface eth1/1/1 mtu 9000 # Set both in one command config interface eth1/1/1 description "My port" mtu 9000 # Apply to multiple interfaces config interface eth1/1/1,eth1/2/1 description "Uplink" mtu 1500 ``` Original change by @hillol-nexthop ## Implementation Details 1. **New `InterfaceConfig` class** - Parses CLI tokens to separate port names from attribute key-value pairs - Supports description and mtu attributes (case-insensitive) - Validates attribute names and detects missing values - Wraps `InterfaceList` for port/interface resolution 2. **Updated CmdConfigInterface** - Changed from pass-through command to functional command - Implements `queryClient` to process description and mtu attributes - Sets `port->description()` for description attribute - Sets `interface->mtu()` for mtu attribute with validation 3. **Updated subcommands to use `InterfaceConfig` instead of `InterfaceList`** 4. **Deleted obsolete pairs of `.cpp` / `.h`** 5. **New comprehensive test suite** - 23 tests covering `InterfaceConfig` validation and `CmdConfigInterface::queryClient` functionality - Backward Compatibility - Subcommands like switchport, pfc-config, and queuing-policy continue to work as before # Test Plan **Note:** The e2e test is already implemented in #893 Above mentioned list of new test file, deleted and updated test files. Existing unit and end to end tests pass. --- cmake/CliFboss2.cmake | 9 +- cmake/CliFboss2Test.cmake | 3 +- fboss/cli/fboss2/BUCK | 7 +- fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 7 - fboss/cli/fboss2/CmdListConfig.cpp | 14 - fboss/cli/fboss2/CmdSubcommands.cpp | 7 + .../config/interface/CmdConfigInterface.cpp | 92 ++++ .../config/interface/CmdConfigInterface.h | 18 +- .../CmdConfigInterfaceDescription.cpp | 48 -- .../interface/CmdConfigInterfaceDescription.h | 43 -- .../interface/CmdConfigInterfaceMtu.cpp | 48 -- .../config/interface/CmdConfigInterfaceMtu.h | 81 --- .../CmdConfigInterfaceQueuingPolicy.cpp | 5 +- .../CmdConfigInterfaceQueuingPolicy.h | 4 +- .../CmdConfigInterfacePfcConfig.cpp | 5 +- .../pfc_config/CmdConfigInterfacePfcConfig.h | 6 +- .../switchport/CmdConfigInterfaceSwitchport.h | 10 +- .../CmdConfigInterfaceSwitchportAccess.h | 10 +- ...CmdConfigInterfaceSwitchportAccessVlan.cpp | 5 +- .../CmdConfigInterfaceSwitchportAccessVlan.h | 4 +- fboss/cli/fboss2/test/BUCK | 3 +- .../CmdConfigInterfaceDescriptionTest.cpp | 218 -------- .../fboss2/test/CmdConfigInterfaceMtuTest.cpp | 218 -------- ...onfigInterfaceSwitchportAccessVlanTest.cpp | 14 +- .../fboss2/test/CmdConfigInterfaceTest.cpp | 481 ++++++++++++++++++ fboss/cli/fboss2/utils/CmdUtilsCommon.h | 1 + fboss/cli/fboss2/utils/InterfacesConfig.cpp | 99 ++++ fboss/cli/fboss2/utils/InterfacesConfig.h | 65 +++ 28 files changed, 797 insertions(+), 728 deletions(-) create mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp delete mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp delete mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h delete mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp delete mode 100644 fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h delete mode 100644 fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp delete mode 100644 fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp create mode 100644 fboss/cli/fboss2/test/CmdConfigInterfaceTest.cpp create mode 100644 fboss/cli/fboss2/utils/InterfacesConfig.cpp create mode 100644 fboss/cli/fboss2/utils/InterfacesConfig.h diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index ba79e4fc87afd..a4305e49aba15 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -593,11 +593,8 @@ 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.cpp - fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h - fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp - fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h 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 @@ -645,8 +642,10 @@ add_library(fboss2_config_lib fboss/cli/fboss2/session/ConfigSession.cpp fboss/cli/fboss2/session/Git.h fboss/cli/fboss2/session/Git.cpp - fboss/cli/fboss2/utils/InterfaceList.h + 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 ) diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index d2014e5c90313..82bcebfdfead4 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -37,10 +37,9 @@ add_executable(fboss2_cmd_test fboss/cli/fboss2/test/TestMain.cpp fboss/cli/fboss2/test/CmdConfigAppliedInfoTest.cpp fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp - fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp - fboss/cli/fboss2/test/CmdConfigInterfaceMtuTest.cpp fboss/cli/fboss2/test/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/CmdConfigSessionDiffTest.cpp diff --git a/fboss/cli/fboss2/BUCK b/fboss/cli/fboss2/BUCK index 856d585dd5902..711d9d4463b59 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -802,10 +802,9 @@ cpp_library( "commands/config/CmdConfigAppliedInfo.cpp", "commands/config/CmdConfigReload.cpp", "commands/config/history/CmdConfigHistory.cpp", - "commands/config/interface/CmdConfigInterfaceDescription.cpp", - "commands/config/interface/CmdConfigInterfaceMtu.cpp", "commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp", "commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp", + "commands/config/interface/CmdConfigInterface.cpp", "commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp", "commands/config/l2/learning_mode/CmdConfigL2LearningMode.cpp", "commands/config/qos/buffer_pool/CmdConfigQosBufferPool.cpp", @@ -821,6 +820,7 @@ cpp_library( "commands/config/vlan/static_mac/delete/CmdConfigVlanStaticMacDelete.cpp", "session/ConfigSession.cpp", "session/Git.cpp", + "utils/InterfacesConfig.cpp", "utils/InterfaceList.cpp", ], headers = [ @@ -828,8 +828,6 @@ 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", @@ -858,6 +856,7 @@ cpp_library( "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/CmdHandlerImplConfig.cpp b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp index 00911340d61a1..e6d960c5436d9 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -19,8 +19,6 @@ #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" @@ -55,11 +53,6 @@ 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< CmdConfigInterfaceQueuingPolicy, CmdConfigInterfaceQueuingPolicyTraits>::run(); diff --git a/fboss/cli/fboss2/CmdListConfig.cpp b/fboss/cli/fboss2/CmdListConfig.cpp index 5d17a28f566e6..665daeb4c27fe 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -15,8 +15,6 @@ #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" @@ -66,18 +64,6 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler, {{ - "description", - "Set interface description", - commandHandler, - argTypeHandler, - }, - { - "mtu", - "Set interface MTU", - commandHandler, - argTypeHandler, - }, - { "pfc-config", "Configure PFC settings for interface", commandHandler, diff --git a/fboss/cli/fboss2/CmdSubcommands.cpp b/fboss/cli/fboss2/CmdSubcommands.cpp index 431e852e328bb..d35ecf5802691 100644 --- a/fboss/cli/fboss2/CmdSubcommands.cpp +++ b/fboss/cli/fboss2/CmdSubcommands.cpp @@ -329,6 +329,13 @@ CLI::App* CmdSubcommands::addCommand( 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/commands/config/interface/CmdConfigInterface.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp new file mode 100644 index 0000000000000..b992e3578c1c0 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.cpp @@ -0,0 +1,92 @@ +/* + * 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/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; +} + +} // 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 73a9f82998f78..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.cpp +++ /dev/null @@ -1,48 +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 - ConfigSession::getInstance().saveConfig(); - - std::string interfaceList = folly::join(", ", interfaces.getNames()); - return "Successfully set description for interface(s) " + interfaceList; -} - -void CmdConfigInterfaceDescription::printOutput(const RetType& logMsg) { - std::cout << logMsg << std::endl; -} - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h deleted file mode 100644 index 2858dc85aee6f..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceDescription.h +++ /dev/null @@ -1,43 +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 "fboss/cli/fboss2/CmdHandler.h" -#include "fboss/cli/fboss2/commands/config/interface/CmdConfigInterface.h" -#include "fboss/cli/fboss2/utils/CmdUtils.h" -#include "fboss/cli/fboss2/utils/InterfaceList.h" - -namespace facebook::fboss { - -struct CmdConfigInterfaceDescriptionTraits : public WriteCommandTraits { - using ParentCmd = CmdConfigInterface; - static constexpr utils::ObjectArgTypeId ObjectArgTypeId = - utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_MESSAGE; - using ObjectArgType = utils::Message; - using RetType = std::string; -}; - -class CmdConfigInterfaceDescription : public CmdHandler< - CmdConfigInterfaceDescription, - CmdConfigInterfaceDescriptionTraits> { - public: - using ObjectArgType = CmdConfigInterfaceDescriptionTraits::ObjectArgType; - using RetType = CmdConfigInterfaceDescriptionTraits::RetType; - - RetType queryClient( - const HostInfo& hostInfo, - const utils::InterfaceList& interfaces, - const ObjectArgType& description); - - void printOutput(const RetType& logMsg); -}; - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp deleted file mode 100644 index a7344c63f7e01..0000000000000 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.cpp +++ /dev/null @@ -1,48 +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 - ConfigSession::getInstance().saveConfig(); - - std::string interfaceList = folly::join(", ", interfaces.getNames()); - std::string message = "Successfully set MTU for interface(s) " + - interfaceList + " to " + std::to_string(mtu); - - return message; -} - -void CmdConfigInterfaceMtu::printOutput( - const CmdConfigInterfaceMtuTraits::RetType& logMsg) { - std::cout << logMsg << std::endl; -} - -} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceMtu.h 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 index 22d2ac6f86354..4895726643bfc 100644 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.cpp @@ -19,15 +19,16 @@ #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/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { CmdConfigInterfaceQueuingPolicyTraits::RetType CmdConfigInterfaceQueuingPolicy::queryClient( const HostInfo& /* hostInfo */, - const utils::InterfaceList& interfaces, + const utils::InterfacesConfig& interfaceConfig, const ObjectArgType& policyNameArg) { + const auto& interfaces = interfaceConfig.getInterfaces(); if (interfaces.empty()) { throw std::invalid_argument("No interface name provided"); } diff --git a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h index 8ae9a4e061dd4..5221e15ce5f65 100644 --- a/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h +++ b/fboss/cli/fboss2/commands/config/interface/CmdConfigInterfaceQueuingPolicy.h @@ -16,7 +16,7 @@ #include "fboss/cli/fboss2/utils/CmdUtils.h" #include "fboss/cli/fboss2/utils/CmdUtilsCommon.h" #include "fboss/cli/fboss2/utils/HostInfo.h" -#include "fboss/cli/fboss2/utils/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { @@ -38,7 +38,7 @@ class CmdConfigInterfaceQueuingPolicy RetType queryClient( const HostInfo& hostInfo, - const utils::InterfaceList& interfaces, + 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 index 1cfeac6d3df2f..b7092e8d0ee9c 100644 --- a/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.cpp @@ -27,7 +27,7 @@ #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/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { @@ -125,8 +125,9 @@ PfcConfigAttrs::PfcConfigAttrs(std::vector v) { CmdConfigInterfacePfcConfigTraits::RetType CmdConfigInterfacePfcConfig::queryClient( const HostInfo& /* hostInfo */, - const InterfaceList& interfaces, + 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) { diff --git a/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h index 22cf278b76e17..9190af2b9b9dc 100644 --- a/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h +++ b/fboss/cli/fboss2/commands/config/interface/pfc_config/CmdConfigInterfacePfcConfig.h @@ -16,12 +16,10 @@ #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/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { -using InterfaceList = utils::InterfaceList; - struct CmdConfigInterfacePfcConfigTraits : public WriteCommandTraits { using ParentCmd = CmdConfigInterface; static constexpr utils::ObjectArgTypeId ObjectArgTypeId = @@ -39,7 +37,7 @@ class CmdConfigInterfacePfcConfig : public CmdHandler< RetType queryClient( const HostInfo& hostInfo, - const InterfaceList& interfaces, + const utils::InterfacesConfig& interfaceConfig, const ObjectArgType& config); void printOutput(const RetType& logMsg); diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h index c32a5eb3e0f32..ef254bada99e5 100644 --- a/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h +++ b/fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h @@ -12,8 +12,7 @@ #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/InterfacesConfig.h" namespace facebook::fboss { @@ -31,7 +30,12 @@ class CmdConfigInterfaceSwitchport : public CmdHandler< public: RetType queryClient( const HostInfo& /* hostInfo */, - const utils::InterfaceList& /* interfaces */) { + 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"); } diff --git a/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h index 0eb3ce70b3556..3306f9de84779 100644 --- a/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/CmdConfigInterfaceSwitchportAccess.h @@ -12,8 +12,7 @@ #include "fboss/cli/fboss2/CmdHandler.h" #include "fboss/cli/fboss2/commands/config/interface/switchport/CmdConfigInterfaceSwitchport.h" -#include "fboss/cli/fboss2/utils/CmdUtils.h" -#include "fboss/cli/fboss2/utils/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { @@ -32,7 +31,12 @@ class CmdConfigInterfaceSwitchportAccess public: RetType queryClient( const HostInfo& /* hostInfo */, - const utils::InterfaceList& /* interfaces */) { + 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"); } 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 index fe4cd1c17df53..95c783ae31e36 100644 --- a/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.cpp @@ -17,10 +17,11 @@ namespace facebook::fboss { CmdConfigInterfaceSwitchportAccessVlanTraits::RetType CmdConfigInterfaceSwitchportAccessVlan::queryClient( - const HostInfo& hostInfo, - const utils::InterfaceList& interfaces, + 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"); } 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 index 584dd1fd6f450..f71779461af8a 100644 --- a/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h +++ b/fboss/cli/fboss2/commands/config/interface/switchport/access/vlan/CmdConfigInterfaceSwitchportAccessVlan.h @@ -13,7 +13,7 @@ #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/InterfaceList.h" +#include "fboss/cli/fboss2/utils/InterfacesConfig.h" namespace facebook::fboss { @@ -40,7 +40,7 @@ class CmdConfigInterfaceSwitchportAccessVlan RetType queryClient( const HostInfo& hostInfo, - const utils::InterfaceList& interfaces, + const utils::InterfacesConfig& interfaceConfig, const ObjectArgType& vlanId); void printOutput(const RetType& logMsg); diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index 5937553d371a9..af68512e9ffe3 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -65,10 +65,9 @@ cpp_unittest( srcs = [ "CmdConfigAppliedInfoTest.cpp", "CmdConfigHistoryTest.cpp", - "CmdConfigInterfaceDescriptionTest.cpp", - "CmdConfigInterfaceMtuTest.cpp", "CmdConfigInterfaceSwitchportAccessVlanTest.cpp", "CmdConfigL2LearningModeTest.cpp", + "CmdConfigInterfaceTest.cpp", "CmdConfigQosBufferPoolTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", diff --git a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp b/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.cpp deleted file mode 100644 index bcf9469da10e1..0000000000000 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceDescriptionTest.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/CmdConfigInterfaceDescription.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/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 - // 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,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" - } - ] - } -})"); - - // 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::path sessionDir = testHomeDir_ / ".fboss2"; - TestableConfigSession::setInstance( - std::make_unique( - sessionDir.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_; -}; - -// 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 index 55adf897b19b5..cfd8f7b7f669e 100644 --- a/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp +++ b/fboss/cli/fboss2/test/CmdConfigInterfaceSwitchportAccessVlanTest.cpp @@ -19,6 +19,7 @@ #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; @@ -209,9 +210,9 @@ TEST_F( auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); VlanIdValue vlanId({"2001"}); - utils::InterfaceList interfaces({"eth1/1/1", "eth1/2/1"}); + utils::InterfacesConfig interfaceConfig({"eth1/1/1", "eth1/2/1"}); - auto result = cmd.queryClient(localhost(), interfaces, vlanId); + auto result = cmd.queryClient(localhost(), interfaceConfig, vlanId); EXPECT_THAT(result, HasSubstr("Successfully set access VLAN")); EXPECT_THAT(result, HasSubstr("eth1/1/1")); @@ -239,13 +240,8 @@ TEST_F( std::make_unique( sessionConfigDir_.string(), systemConfigDir_.string())); - auto cmd = CmdConfigInterfaceSwitchportAccessVlan(); - VlanIdValue vlanId({"100"}); - - utils::InterfaceList emptyInterfaces({}); - EXPECT_THROW( - cmd.queryClient(localhost(), emptyInterfaces, vlanId), - std::invalid_argument); + // 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/utils/CmdUtilsCommon.h b/fboss/cli/fboss2/utils/CmdUtilsCommon.h index f779278a945c7..c84d7ec7a3005 100644 --- a/fboss/cli/fboss2/utils/CmdUtilsCommon.h +++ b/fboss/cli/fboss2/utils/CmdUtilsCommon.h @@ -90,6 +90,7 @@ enum class ObjectArgTypeId : uint8_t { 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 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