// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/test/base/chromeos/crosier/upstart.h"
#include "base/process/launch.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "base/test/test_timeouts.h"
#include "base/threading/platform_thread.h"
#include "base/time/time.h"
#include "base/types/expected.h"
#include "chrome/test/base/chromeos/crosier/helper/test_sudo_helper_client.h"
namespace upstart {
namespace {
struct GoalMapping {
const char* str = nullptr;
Goal goal = Goal::kInvalid;
};
const GoalMapping kAllGoals[] = {{"start", Goal::kStart},
{"stop", Goal::kStop}};
struct StateMapping {
const char* str = nullptr;
State state = State::kInvalid;
};
const StateMapping kAllStates[] = {
{"waiting", State::kWaiting}, {"starting", State::kStarting},
{"security", State::kSecurity}, {"tmpfiles", State::kTmpfiles},
{"pre-start", State::kPreStart}, {"spawned", State::kSpawned},
{"post-start", State::kPostStart}, {"running", State::kRunning},
{"pre-stop", State::kPreStop}, {"stopping", State::kStopping},
{"killed", State::kKilled}, {"post-stop", State::kPostStop}};
// Special-cased in StopJob due to its respawning behavior.
const char kUiJob[] = "ui";
// Runs the given command vector as sudo using the test_sudo_helper that should
// be set up on the system.
//
// test_sudo_helper runs the string through the shell so it should be escaped
// accordingly. Currently we have no need for escaping so just assert for some
// simple cases to catch if somebody starts doing this. If things like spaces
// and quotes are needed in the future, we should update the test sudo helper to
// take an array rather than trying to escape here.
TestSudoHelperClient::Result RunSudoHelper(
const std::vector<std::string>& args) {
std::string cmd;
for (const std::string& cur : args) {
// The string should not need escaping (see above).
CHECK(cur.find_first_of(" \\*\"?$&();<>[]^`") == std::string::npos);
if (!cmd.empty()) {
cmd.push_back(' ');
}
cmd.append(cur);
}
return TestSudoHelperClient::ConnectAndRunCommand(cmd);
}
} // namespace
namespace internal {
// Examples:
// "temp_logger stop/waiting"
// "log-rotate start/running, process 1779"
// "ml-service (mojo_service) start/running, process 6820"
// "ui start/tmpfiles, (tmpfiles) process 19419"
JobStatus ParseStatus(std::string_view job_name, std::string_view s) {
s = base::TrimWhitespaceASCII(s, base::TRIM_ALL);
// Expect a prefix of the job name, trim it.
if (!base::StartsWith(s, job_name)) {
return JobStatus();
}
s = s.substr(job_name.size());
// Spaces.
s = base::TrimString(s, " ", base::TRIM_LEADING);
// Optional instance status in parens.
if (s.empty()) {
return JobStatus();
}
if (s[0] == '(') {
// Eat until closing.
if (auto close = s.find(')'); close != std::string_view::npos) {
s = base::TrimString(s.substr(close + 1), " ", base::TRIM_LEADING);
} else {
return JobStatus(); // No closing paren.
}
}
// Spaces.
s = base::TrimString(s, " ", base::TRIM_LEADING);
// Goal string until '/'.
Goal goal = Goal::kInvalid;
if (auto slash = s.find('/'); slash != std::string_view::npos) {
goal = GoalFromString(s.substr(0, slash));
if (goal == Goal::kInvalid) {
return JobStatus();
}
s = s.substr(slash + 1); // Eat the goal plus the slash.
} else {
return JobStatus(); // No slash.
}
// State until comma or end of string.
State state = State::kInvalid;
if (auto comma = s.find(','); comma != std::string_view::npos) {
state = StateFromString(s.substr(0, comma));
s = s.substr(comma + 1); // Eat the status and comma.
} else {
// No status terminator, everything to end-of-string is the status.
state = StateFromString(s);
}
if (state == State::kInvalid) {
return JobStatus();
}
// Spaces.
s = base::TrimString(s, " ", base::TRIM_LEADING);
// Optional "(tmpfiles)" annotation.
std::string_view tmpfiles_annotation("(tmpfiles)");
if (base::StartsWith(s, tmpfiles_annotation)) {
s = s.substr(tmpfiles_annotation.size());
s = base::TrimString(s, " ", base::TRIM_LEADING);
}
// Optional "process" annotation with the PID.
std::string_view process_annotation("process");
int pid = 0;
if (base::StartsWith(s, process_annotation)) {
s = s.substr(process_annotation.size());
s = base::TrimString(s, " ", base::TRIM_LEADING);
// Everything to the end or whitespace (could have more lines of other
// instances following) is the PID.
std::string_view pid_str;
if (auto pid_end = s.find_first_of(base::kWhitespaceASCII);
pid_end != std::string_view::npos) {
pid_str = s.substr(0, pid_end);
} else {
pid_str = s;
}
if (!base::StringToInt(pid_str, &pid)) {
return JobStatus();
}
}
return JobStatus{true, goal, state, pid};
}
bool WaitForJobStatusWithTimeout(const std::string& job,
Goal goal,
State state,
WrongGoalPolicy policy,
base::TimeDelta timeout,
const std::vector<std::string>& extra_args) {
base::TimeTicks deadline = base::TimeTicks::Now() + timeout;
while (true) {
JobStatus status = GetJobStatus(job, extra_args);
if (!status.is_valid) {
return false; // Failure checking.
}
if (status.goal == goal && status.state == state) {
return true; // Done.
}
if (status.goal != goal && policy == WrongGoalPolicy::kReject) {
return false; // Wrong goal.
}
if (base::TimeTicks::Now() > deadline) {
return false; // Timeout.
}
// Uses 50ms from base/test/spin_wait.h (which we can't use because of our
// extra error handling).
base::PlatformThread::Sleep(base::Milliseconds(50));
}
}
} // namespace internal
Goal GoalFromString(std::string_view s) {
for (const GoalMapping& map : kAllGoals) {
if (map.str == s) {
return map.goal;
}
}
return Goal::kInvalid;
}
std::string_view GoalToString(Goal g) {
for (const GoalMapping& map : kAllGoals) {
if (map.goal == g) {
return map.str;
}
}
return "<invalid>";
}
State StateFromString(std::string_view s) {
for (const StateMapping& map : kAllStates) {
if (map.str == s) {
return map.state;
}
}
return State::kInvalid;
}
std::string_view StateFromString(State s) {
for (const StateMapping& map : kAllStates) {
if (map.state == s) {
return map.str;
}
}
return "<invalid>";
}
bool DumpJobs() {
std::vector<std::string> args{"initctl", "list"};
TestSudoHelperClient::Result result = RunSudoHelper(args);
if (result.return_code != 0) {
return false;
}
printf("%s", result.output.c_str());
return true;
}
JobStatus GetJobStatus(const std::string& job,
const std::vector<std::string>& extra_args) {
// This does not need to use the sudo helper because it's read-only.
std::vector<std::string> args{"initctl", "status", job};
args.insert(args.end(), extra_args.begin(), extra_args.end());
TestSudoHelperClient::Result result = RunSudoHelper(args);
// Tolerates an error if stderr starts with "initctl: Unknown instance". This
// happens when the job is multiple-instance and the specific instance is
// treated as stop/waiting.
if (base::StartsWith(result.output, "initctl: Unknown instance")) {
return JobStatus{true, Goal::kStop, State::kWaiting, 0};
}
// Any other error is a failure.
if (result.return_code != 0) {
LOG(ERROR) << "Running initctl failed: " << result.output;
return JobStatus();
}
return internal::ParseStatus(job, result.output);
}
bool JobExists(const std::string& job) {
return GetJobStatus(job).is_valid;
}
bool StartJob(const std::string& job,
const std::vector<std::string>& extra_args) {
std::vector<std::string> args{"initctl", "start", job};
args.insert(args.end(), extra_args.begin(), extra_args.end());
return RunSudoHelper(args).return_code == 0;
}
bool RestartJob(const std::string& job,
const std::vector<std::string>& extra_args) {
if (!StopJob(job, extra_args)) {
return false;
}
return StartJob(job, extra_args);
}
bool StopJob(const std::string& job,
const std::vector<std::string>& extra_args) {
if (job == kUiJob) {
// To Go version of this function does some special-casing for the ui job.
// The ui job needs special handling because it is restarted out-of-band by
// the ui-respawn job when session_manager exits. To work around this, when
// job is "ui", we would first need to waits for the job to reach a stable
// state. See https://crbug.com/891594.
//
// If this is needed, the necessary logic should be added here.
CHECK(false) << "StopJob unimplemented for the 'ui' job.";
return false;
}
std::vector<std::string> args{"initctl", "stop", job};
args.insert(args.end(), extra_args.begin(), extra_args.end());
// Issue the stop request, ignoring errors from initctl (these will be thrown
// if the job is already stopped).
RunSudoHelper(args);
// Check the actual status.
return WaitForJobStatus(job, Goal::kStop, State::kWaiting,
WrongGoalPolicy::kReject, extra_args);
}
bool WaitForJobStatus(const std::string& job,
Goal goal,
State state,
WrongGoalPolicy policy,
const std::vector<std::string>& extra_args) {
return internal::WaitForJobStatusWithTimeout(
job, goal, state, policy, TestTimeouts::action_timeout(), extra_args);
}
bool EnsureJobRunning(const std::string& job,
const std::vector<std::string>& extra_args) {
// If the job already has a "start" goal, wait for it to enter the "running"
// state. This will return nil immediately if it's already start/running, and
// will return an error immediately if the job has a "stop" goal.
if (WaitForJobStatus(job, Goal::kStart, State::kRunning,
WrongGoalPolicy::kReject, extra_args)) {
return true;
}
// Otherwise it needs starting.
return StartJob(job, extra_args);
}
} // namespace upstart