From 14fdc3695bdad08aaa28f9f86c03e2fcf198bec0 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Tue, 23 Dec 2025 16:56:42 +0000 Subject: [PATCH] Add a `fboss2 config history` command. --- cmake/CliFboss2.cmake | 2 + cmake/CliFboss2Test.cmake | 1 + fboss/cli/fboss2/BUCK | 2 + fboss/cli/fboss2/CmdHandlerImplConfig.cpp | 2 + fboss/cli/fboss2/CmdListConfig.cpp | 7 + .../config/history/CmdConfigHistory.cpp | 162 +++++++++++ .../config/history/CmdConfigHistory.h | 37 +++ fboss/cli/fboss2/test/BUCK | 1 + .../cli/fboss2/test/CmdConfigHistoryTest.cpp | 263 ++++++++++++++++++ 9 files changed, 477 insertions(+) create mode 100644 fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp create mode 100644 fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h create mode 100644 fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp diff --git a/cmake/CliFboss2.cmake b/cmake/CliFboss2.cmake index c361ef374ffd3..929660ae17c36 100644 --- a/cmake/CliFboss2.cmake +++ b/cmake/CliFboss2.cmake @@ -575,6 +575,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/history/CmdConfigHistory.h + fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.cpp fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h diff --git a/cmake/CliFboss2Test.cmake b/cmake/CliFboss2Test.cmake index 39b524eb7371b..82488671f61ab 100644 --- a/cmake/CliFboss2Test.cmake +++ b/cmake/CliFboss2Test.cmake @@ -34,6 +34,7 @@ gtest_discover_tests(fboss2_framework_test) 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/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 fddd9ae247522..86c53d4bf7fcf 100644 --- a/fboss/cli/fboss2/BUCK +++ b/fboss/cli/fboss2/BUCK @@ -771,6 +771,7 @@ cpp_library( "CmdListConfig.cpp", "commands/config/CmdConfigAppliedInfo.cpp", "commands/config/CmdConfigReload.cpp", + "commands/config/history/CmdConfigHistory.cpp", "commands/config/rollback/CmdConfigRollback.cpp", "commands/config/session/CmdConfigSessionCommit.cpp", "commands/config/session/CmdConfigSessionDiff.cpp", @@ -779,6 +780,7 @@ cpp_library( headers = [ "commands/config/CmdConfigAppliedInfo.h", "commands/config/CmdConfigReload.h", + "commands/config/history/CmdConfigHistory.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 6c394fcaafb35..6b2f8ac117f64 100644 --- a/fboss/cli/fboss2/CmdHandlerImplConfig.cpp +++ b/fboss/cli/fboss2/CmdHandlerImplConfig.cpp @@ -12,6 +12,7 @@ #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" +#include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -21,6 +22,7 @@ namespace facebook::fboss { 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 acdbc61d78c6e..254e99acbfcff 100644 --- a/fboss/cli/fboss2/CmdListConfig.cpp +++ b/fboss/cli/fboss2/CmdListConfig.cpp @@ -13,6 +13,7 @@ #include "fboss/cli/fboss2/CmdHandler.h" #include "fboss/cli/fboss2/commands/config/CmdConfigAppliedInfo.h" #include "fboss/cli/fboss2/commands/config/CmdConfigReload.h" +#include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h" #include "fboss/cli/fboss2/commands/config/rollback/CmdConfigRollback.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionCommit.h" #include "fboss/cli/fboss2/commands/config/session/CmdConfigSessionDiff.h" @@ -27,6 +28,12 @@ const CommandTree& kConfigCommandTree() { commandHandler, argTypeHandler}, + {"config", + "history", + "Show history of committed config revisions", + commandHandler, + argTypeHandler}, + { "config", "session", diff --git a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp new file mode 100644 index 0000000000000..b5c3755bd38b6 --- /dev/null +++ b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.cpp @@ -0,0 +1,162 @@ +/* + * 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/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]; + tm timeinfo{}; + localtime_r(&timeSec, &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; +} + +} // namespace + +CmdConfigHistoryTraits::RetType CmdConfigHistory::queryClient( + const HostInfo& hostInfo) { + auto& session = ConfigSession::getInstance(); + const std::string cliConfigDir = session.getCliConfigDir(); + + auto revisions = collectRevisions(cliConfigDir); + + if (revisions.empty()) { + return "No config revisions found in " + cliConfigDir; + } + + // Build the table + utils::Table table; + table.setHeader({"Revision", "Owner", "Commit Time"}); + + for (const auto& rev : revisions) { + table.addRow( + {"r" + std::to_string(rev.revisionNumber), + rev.owner, + formatTime(rev.commitTimeNsec)}); + } + + // Convert table to string + std::ostringstream oss; + oss << table; + return oss.str(); +} + +void CmdConfigHistory::printOutput(const RetType& tableOutput) { + std::cout << tableOutput << std::endl; +} + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.h new file mode 100644 index 0000000000000..44008e5e28c7d --- /dev/null +++ b/fboss/cli/fboss2/commands/config/history/CmdConfigHistory.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" +#include "fboss/cli/fboss2/utils/CmdClientUtils.h" +#include "fboss/cli/fboss2/utils/CmdUtils.h" + +namespace facebook::fboss { + +struct CmdConfigHistoryTraits : public WriteCommandTraits { + static constexpr utils::ObjectArgTypeId ObjectArgTypeId = + utils::ObjectArgTypeId::OBJECT_ARG_TYPE_ID_NONE; + using ObjectArgType = std::monostate; + using RetType = std::string; +}; + +class CmdConfigHistory + : public CmdHandler { + public: + using ObjectArgType = CmdConfigHistoryTraits::ObjectArgType; + using RetType = CmdConfigHistoryTraits::RetType; + + RetType queryClient(const HostInfo& hostInfo); + + void printOutput(const RetType& tableOutput); +}; + +} // namespace facebook::fboss diff --git a/fboss/cli/fboss2/test/BUCK b/fboss/cli/fboss2/test/BUCK index ff4a273e8400e..44304ca601e42 100644 --- a/fboss/cli/fboss2/test/BUCK +++ b/fboss/cli/fboss2/test/BUCK @@ -62,6 +62,7 @@ cpp_unittest( name = "cmd_test", srcs = [ "CmdConfigAppliedInfoTest.cpp", + "CmdConfigHistoryTest.cpp", "CmdConfigReloadTest.cpp", "CmdConfigSessionDiffTest.cpp", "CmdConfigSessionTest.cpp", diff --git a/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp b/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp new file mode 100644 index 0000000000000..5db56df97a947 --- /dev/null +++ b/fboss/cli/fboss2/test/CmdConfigHistoryTest.cpp @@ -0,0 +1,263 @@ +// (c) Facebook, Inc. and its affiliates. Confidential and proprietary. + +#include +#include +#include +#include + +#include "fboss/cli/fboss2/commands/config/history/CmdConfigHistory.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/PortMap.h" + +#include +#include + +namespace fs = std::filesystem; + +using namespace ::testing; + +namespace facebook::fboss { + +class CmdConfigHistoryTestFixture : public CmdHandlerTestBase { + public: + fs::path testHomeDir_; + fs::path testEtcDir_; + fs::path systemConfigPath_; + fs::path sessionConfigPath_; + fs::path cliConfigDir_; + + void SetUp() override { + CmdHandlerTestBase::SetUp(); + + // Create unique test directories for each test to avoid conflicts when + // running tests in parallel + auto tempBase = fs::temp_directory_path(); + auto uniquePath = boost::filesystem::unique_path( + "fboss2_config_history_test_%%%%-%%%%-%%%%-%%%%"); + testHomeDir_ = tempBase / (uniquePath.string() + "_home"); + testEtcDir_ = tempBase / (uniquePath.string() + "_etc"); + + // Clean up any previous test artifacts (shouldn't exist with unique names) + std::error_code ec; + fs::remove_all(testHomeDir_, ec); + fs::remove_all(testEtcDir_, ec); + + // Create fresh test directories + fs::create_directories(testHomeDir_); + fs::create_directories(testEtcDir_); + + // Set up paths + systemConfigPath_ = testEtcDir_ / "agent.conf"; + sessionConfigPath_ = testHomeDir_ / ".fboss2" / "agent.conf"; + cliConfigDir_ = testEtcDir_ / "coop" / "cli"; + + // Create CLI config directory + fs::create_directories(cliConfigDir_); + + // Create a default system config + createTestConfig( + systemConfigPath_, + R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000 + } + ] + } +})"); + } + + void TearDown() override { + // Reset the singleton to ensure tests don't interfere with each other + TestableConfigSession::setInstance(nullptr); + // Clean up test directories (use error_code to avoid exceptions) + std::error_code ec; + fs::remove_all(testHomeDir_, ec); + fs::remove_all(testEtcDir_, ec); + CmdHandlerTestBase::TearDown(); + } + + void createTestConfig(const fs::path& path, const std::string& content) { + fs::create_directories(path.parent_path()); + 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(); + } + + void initializeTestSession() { + // Ensure system config exists before initializing session + if (!fs::exists(systemConfigPath_)) { + createTestConfig( + systemConfigPath_, + R"({ + "sw": { + "ports": [ + { + "logicalID": 1, + "name": "eth1/1/1", + "state": 2, + "speed": 100000 + } + ] + } +})"); + } + + // Ensure the parent directory of session config exists + fs::create_directories(sessionConfigPath_.parent_path()); + + // Initialize ConfigSession singleton with test paths + TestableConfigSession::setInstance( + std::make_unique( + sessionConfigPath_.string(), + systemConfigPath_.string(), + cliConfigDir_.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}]}})"); + createTestConfig( + cliConfigDir_ / "agent-r2.conf", + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + createTestConfig( + cliConfigDir_ / "agent-r3.conf", + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + + // 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("Commit Time"), std::string::npos); +} + +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}]}})"); + createTestConfig( + cliConfigDir_ / "agent-r2.conf", + R"({"sw": {"ports": [{"logicalID": 1, "name": "eth1/1/1", "state": 2, "speed": 100000}]}})"); + + // 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})"); + + // 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); +} + +TEST_F(CmdConfigHistoryTestFixture, historyEmptyDirectory) { + // Directory exists but has no revision files + EXPECT_TRUE(fs::exists(cliConfigDir_)); + + // Initialize ConfigSession singleton with test paths + initializeTestSession(); + + // Create and execute the command + 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); +} + +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}]}})"); + + // 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); +} + +TEST_F(CmdConfigHistoryTestFixture, printOutput) { + auto cmd = CmdConfigHistory(); + std::string tableOutput = + "Revision Owner Commit Time\nr1 user1 2024-01-01 12:00:00.000"; + + // Redirect cout to capture output + std::stringstream buffer; + std::streambuf* old = std::cout.rdbuf(buffer.rdbuf()); + + cmd.printOutput(tableOutput); + + // Restore cout + std::cout.rdbuf(old); + + 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("user1"), std::string::npos); +} + +} // namespace facebook::fboss