Verified Commit ddef2bda authored by Michael Usachenko's avatar Michael Usachenko Committed by GitLab
Browse files

feat(xtask): handle cng post-deployment tasks for e2e harness

parent c72c7add
Loading
Loading
Loading
Loading
+14 −0
Original line number Diff line number Diff line
@@ -28,3 +28,17 @@ pub fn succeeds(sh: &Shell, program: &str, args: &[&str]) -> bool {
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Run a command and capture its stdout as a trimmed string.
/// Returns `None` if the command fails.
pub fn capture(sh: &Shell, program: &str, args: &[&str]) -> Option<String> {
    cmd!(sh, "{program}")
        .args(args)
        .quiet()
        .ignore_status()
        .ignore_stderr()
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
+59 −56
Original line number Diff line number Diff line
//! E2E environment configuration.
//!
//! All configurable values for the E2E harness. Each field can be overridden
//! via the corresponding environment variable.
//! All configurable values for the E2E harness. Infrastructure defaults
//! (namespaces, PG pod names, in-pod paths, etc.) are stable and have
//! sensible fallbacks. User-specific paths like `GITLAB_SRC` are **required**
//! — the binary panics at startup if they are not set, rather than silently
//! using a path that only works on one developer's machine.

use std::env;
use std::path::PathBuf;

/// Resolve `~` at the start of a path to `$HOME`.
fn expand_home(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/")
        && let Ok(home) = env::var("HOME")
    {
        return PathBuf::from(home).join(rest);
    }
    PathBuf::from(path)
}

/// Read an env var or return the default.
fn env_or(key: &str, default: &str) -> String {
    env::var(key).unwrap_or_else(|_| default.to_string())
}
use super::constants as c;
use super::env as e;

/// All configuration for the E2E environment.
pub struct Config {
@@ -28,8 +19,12 @@ pub struct Config {
    pub gkg_root: PathBuf,
    /// Path to the e2e/cng directory (contains Dockerfile, values files).
    pub cng_dir: PathBuf,
    /// Path to the Tilt directory (e2e/tilt).
    pub tilt_dir: PathBuf,
    /// Path to the local GitLab Rails checkout.
    pub gitlab_src: PathBuf,
    /// Log / artifact output directory (.dev/).
    pub log_dir: PathBuf,

    // -- Colima / k8s ---------------------------------------------------------
    pub colima_profile: String,
@@ -40,6 +35,7 @@ pub struct Config {

    // -- Kubernetes namespaces ------------------------------------------------
    pub gitlab_ns: String,
    pub default_ns: String,

    // -- CNG image settings ---------------------------------------------------
    pub base_tag: String,
@@ -48,43 +44,73 @@ pub struct Config {
    pub local_tag: String,
    pub workhorse_image: String,
    pub cng_components: Vec<String>,

    // -- PostgreSQL ------------------------------------------------------------
    pub pg_secret_name: String,
    pub pg_password_key: String,
    pub pg_superpass_key: String,
    pub pg_pod: String,
    pub pg_database: String,
    pub pg_user: String,

    // -- Paths inside pods ----------------------------------------------------
    pub rails_root: String,
    pub jwt_secret_path: String,
    pub e2e_pod_dir: String,
    pub manifest_pod_path: String,
}

impl Config {
    /// Build config from environment variables with sensible defaults.
    pub fn from_env() -> Self {
        let gkg_root = workspace_root();
        let gkg_root = e::workspace_root();
        let cng_dir = gkg_root.join("e2e/cng");
        let tilt_dir = gkg_root.join("e2e/tilt");
        let log_dir = gkg_root.join(".dev");

        let base_tag = env_or("BASE_TAG", "v18.8.1");
        let cng_registry = env_or("CNG_REGISTRY", "registry.gitlab.com/gitlab-org/build/cng");
        let local_prefix = env_or("LOCAL_PREFIX", "gkg-e2e");
        let local_tag = env_or("LOCAL_TAG", "local");
        let base_tag = e::env_or("BASE_TAG", c::BASE_TAG);
        let cng_registry = e::env_or("CNG_REGISTRY", c::CNG_REGISTRY);
        let local_prefix = e::env_or("LOCAL_PREFIX", c::LOCAL_PREFIX);
        let local_tag = e::env_or("LOCAL_TAG", c::LOCAL_TAG);
        let workhorse_image = format!("{cng_registry}/gitlab-workhorse-ee:{base_tag}");

        let e2e_pod_dir = e::env_or("E2E_POD_DIR", c::E2E_POD_DIR);
        let manifest_pod_path = format!("{e2e_pod_dir}/manifest.json");

        Self {
            gitlab_src: expand_home(&env_or("GITLAB_SRC", "~/Desktop/Code/gdk/gitlab")),
            gitlab_src: e::expand_home(&e::require("GITLAB_SRC")),
            cng_dir,
            tilt_dir,
            log_dir,
            gkg_root,

            colima_profile: env_or("COLIMA_PROFILE", "cng"),
            colima_memory: env_or("COLIMA_MEMORY", "12"),
            colima_cpus: env_or("COLIMA_CPUS", "4"),
            colima_disk: env_or("COLIMA_DISK", "60"),
            colima_k8s_version: env_or("COLIMA_K8S_VERSION", "v1.31.5+k3s1"),
            colima_profile: e::env_or("COLIMA_PROFILE", c::COLIMA_PROFILE),
            colima_memory: e::env_or("COLIMA_MEMORY", c::COLIMA_MEMORY),
            colima_cpus: e::env_or("COLIMA_CPUS", c::COLIMA_CPUS),
            colima_disk: e::env_or("COLIMA_DISK", c::COLIMA_DISK),
            colima_k8s_version: e::env_or("COLIMA_K8S_VERSION", c::COLIMA_K8S_VERSION),

            gitlab_ns: env_or("GITLAB_NS", "gitlab"),
            gitlab_ns: e::env_or("GITLAB_NS", c::GITLAB_NS),
            default_ns: e::env_or("DEFAULT_NS", c::DEFAULT_NS),

            cng_components: vec![
                "gitlab-webservice-ee".into(),
                "gitlab-sidekiq-ee".into(),
                "gitlab-toolbox-ee".into(),
            ],
            cng_components: c::CNG_COMPONENTS.iter().map(|s| (*s).into()).collect(),
            base_tag,
            cng_registry,
            local_prefix,
            local_tag,
            workhorse_image,

            pg_secret_name: e::env_or("PG_SECRET_NAME", c::PG_SECRET_NAME),
            pg_password_key: e::env_or("PG_PASSWORD_KEY", c::PG_PASSWORD_KEY),
            pg_superpass_key: e::env_or("PG_SUPERPASS_KEY", c::PG_SUPERPASS_KEY),
            pg_pod: e::env_or("PG_POD", c::PG_POD),
            pg_database: e::env_or("PG_DATABASE", c::PG_DATABASE),
            pg_user: e::env_or("PG_USER", c::PG_USER),

            rails_root: e::env_or("RAILS_ROOT", c::RAILS_ROOT),
            jwt_secret_path: e::env_or("JWT_SECRET_PATH", c::JWT_SECRET_PATH),
            e2e_pod_dir,
            manifest_pod_path,
        }
    }

@@ -94,26 +120,3 @@ impl Config {
        format!("unix://{home}/.colima/{}/docker.sock", self.colima_profile)
    }
}

/// Find the workspace root by walking up from the xtask binary location.
/// Falls back to CARGO_MANIFEST_DIR at compile time.
fn workspace_root() -> PathBuf {
    // At runtime: the binary is at <root>/target/debug/xtask.
    // Walk up from current exe to find Cargo.toml with [workspace].
    if let Ok(exe) = env::current_exe() {
        let mut dir = exe.as_path();
        while let Some(parent) = dir.parent() {
            if parent.join("Cargo.toml").exists() && parent.join("crates").exists() {
                return parent.to_path_buf();
            }
            dir = parent;
        }
    }

    // Compile-time fallback: xtask's Cargo.toml is at crates/xtask/
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .map(|p| p.to_path_buf())
        .expect("could not determine workspace root")
}
+76 −0
Original line number Diff line number Diff line
//! Default values for E2E configuration.
//!
//! Every constant here is the fallback used when the corresponding
//! environment variable is not set. Gathered in one place so they are
//! easy to audit and update across releases.
//!
//! User-specific paths (e.g. GITLAB_SRC) are intentionally absent —
//! those are required env vars with no fallback.

// -- Colima / k8s -------------------------------------------------------------

pub const COLIMA_PROFILE: &str = "cng";
pub const COLIMA_MEMORY: &str = "12";
pub const COLIMA_CPUS: &str = "4";
pub const COLIMA_DISK: &str = "60";
pub const COLIMA_K8S_VERSION: &str = "v1.31.5+k3s1";

// -- Kubernetes namespaces ----------------------------------------------------

pub const GITLAB_NS: &str = "gitlab";
pub const DEFAULT_NS: &str = "default";

// -- CNG image settings -------------------------------------------------------

pub const BASE_TAG: &str = "v18.8.1";
pub const CNG_REGISTRY: &str = "registry.gitlab.com/gitlab-org/build/cng";
pub const LOCAL_PREFIX: &str = "gkg-e2e";
pub const LOCAL_TAG: &str = "local";

pub const CNG_COMPONENTS: &[&str] = &[
    "gitlab-webservice-ee",
    "gitlab-sidekiq-ee",
    "gitlab-toolbox-ee",
];

/// Directories staged from the GitLab checkout into the temp build context.
pub const STAGING_DIRS: &[&str] = &["app", "config", "db", "ee", "lib", "locale", "gems"];

// -- PostgreSQL ---------------------------------------------------------------

pub const PG_SECRET_NAME: &str = "gitlab-postgresql-password";
pub const PG_PASSWORD_KEY: &str = "postgresql-password";
pub const PG_SUPERPASS_KEY: &str = "postgresql-postgres-password";
pub const PG_POD: &str = "postgresql-0";
pub const PG_DATABASE: &str = "gitlabhq_production";
pub const PG_USER: &str = "gitlab";

// -- Paths inside pods --------------------------------------------------------

pub const RAILS_ROOT: &str = "/srv/gitlab";
pub const JWT_SECRET_PATH: &str = "/etc/gitlab/shell/.gitlab_shell_secret";
pub const E2E_POD_DIR: &str = "/tmp/e2e";

// -- Pod readiness checks -----------------------------------------------------

/// (label selector, timeout) pairs for GitLab pod readiness.
pub const POD_READINESS_CHECKS: &[(&str, &str)] = &[
    ("app.kubernetes.io/name=postgresql", "600s"),
    ("app=webservice", "600s"),
    ("app=sidekiq", "600s"),
    ("app=toolbox", "300s"),
    ("app=gitaly", "300s"),
];

// -- Log / artifact files cleaned during teardown -----------------------------

pub const TEARDOWN_LOG_FILES: &[&str] = &[
    "create-test-data.log",
    "manifest.json",
    "colima-start.log",
    "tilt-ci.log",
    "tilt-ci.pid",
    "clickhouse-migrate.log",
    "redaction-test.log",
    "tilt-e2e.log",
];
+49 −0
Original line number Diff line number Diff line
//! Environment and path utilities.

use std::env;
use std::path::PathBuf;

/// Read an env var or return the default.
pub fn env_or(key: &str, default: &str) -> String {
    env::var(key).unwrap_or_else(|_| default.to_string())
}

/// Read a required env var. Panics with a clear message if not set.
pub fn require(key: &str) -> String {
    env::var(key).unwrap_or_else(|_| {
        panic!("{key} environment variable is required but not set");
    })
}

/// Resolve `~` at the start of a path to `$HOME`.
pub fn expand_home(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/")
        && let Ok(home) = env::var("HOME")
    {
        return PathBuf::from(home).join(rest);
    }
    PathBuf::from(path)
}

/// Find the workspace root by walking up from the xtask binary location.
/// Falls back to `CARGO_MANIFEST_DIR` at compile time.
pub fn workspace_root() -> PathBuf {
    // At runtime: the binary is at <root>/target/debug/xtask.
    // Walk up from current exe to find Cargo.toml with [workspace].
    if let Ok(exe) = env::current_exe() {
        let mut dir = exe.as_path();
        while let Some(parent) = dir.parent() {
            if parent.join("Cargo.toml").exists() && parent.join("crates").exists() {
                return parent.to_path_buf();
            }
            dir = parent;
        }
    }

    // Compile-time fallback: xtask's Cargo.toml is at crates/xtask/
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .map(|p| p.to_path_buf())
        .expect("could not determine workspace root")
}
+182 −0
Original line number Diff line number Diff line
//! Kubernetes / Helm helpers built on top of [`xshell`].
//!
//! Shared operations used across CNG deploy, CNG setup, teardown, and
//! (eventually) the GKG stack phase.

use anyhow::{Context, Result, bail};
use xshell::{Shell, cmd};

use super::cmd as cmd_helpers;
use super::config::Config;
use super::ui;

// -- Helm ---------------------------------------------------------------------

/// Check whether a Helm release exists in the given namespace.
pub fn helm_release_exists(sh: &Shell, release: &str, namespace: &str, docker_host: &str) -> bool {
    cmd!(sh, "helm status {release} -n {namespace}")
        .env("DOCKER_HOST", docker_host)
        .quiet()
        .ignore_status()
        .ignore_stdout()
        .ignore_stderr()
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

// -- Pod readiness ------------------------------------------------------------

/// Block until a pod matching `label` in `namespace` is ready, or warn on timeout.
pub fn wait_for_pod(sh: &Shell, label: &str, namespace: &str, timeout: &str) -> Result<()> {
    ui::info(&format!(
        "Waiting for pod ({label}) in {namespace} (timeout {timeout})"
    ))?;
    let timeout_arg = format!("--timeout={timeout}");
    let ok = cmd!(
        sh,
        "kubectl wait --for=condition=ready pod
            -l {label}
            -n {namespace}
            {timeout_arg}"
    )
    .quiet()
    .ignore_status()
    .ignore_stdout()
    .ignore_stderr()
    .output()
    .map(|o| o.status.success())
    .unwrap_or(false);

    if !ok {
        ui::warn(&format!(
            "Pod {label} not ready after {timeout}. Continuing..."
        ))?;
    }
    Ok(())
}

// -- Toolbox pod --------------------------------------------------------------

/// Resolve the toolbox pod name in the gitlab namespace.
pub fn get_toolbox_pod(sh: &Shell, cfg: &Config) -> Result<String> {
    let ns = &cfg.gitlab_ns;
    let jsonpath = "{.items[0].metadata.name}";
    let pod = cmd_helpers::capture(
        sh,
        "kubectl",
        &[
            "get",
            "pod",
            "-n",
            ns,
            "-l",
            "app=toolbox",
            "-o",
            &format!("jsonpath={jsonpath}"),
        ],
    );

    match pod {
        Some(name) if !name.is_empty() => Ok(name),
        _ => bail!(
            "No toolbox pod found in {ns} namespace.\n\
             Is GitLab deployed? Run `cargo xtask e2e setup` first."
        ),
    }
}

/// Run an arbitrary command inside the toolbox pod.
pub fn toolbox_exec(sh: &Shell, cfg: &Config, pod: &str, command: &[&str]) -> Result<String> {
    let ns = &cfg.gitlab_ns;
    let output = cmd!(sh, "kubectl exec -n {ns} {pod} --")
        .args(command)
        .quiet()
        .ignore_status()
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("toolbox exec failed: {stderr}");
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Run a one-liner Ruby command via `rails runner` in the toolbox pod.
pub fn toolbox_rails_eval(sh: &Shell, cfg: &Config, pod: &str, ruby_cmd: &str) -> Result<String> {
    let ns = &cfg.gitlab_ns;
    let rails_root = &cfg.rails_root;
    let bash_cmd =
        format!("cd {rails_root} && bundle exec rails runner '{ruby_cmd}' RAILS_ENV=production");

    let output = cmd!(sh, "kubectl exec -n {ns} {pod} -- bash -c {bash_cmd}")
        .quiet()
        .ignore_status()
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("rails runner failed: {stderr}");
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

// -- Secrets ------------------------------------------------------------------

/// Read a k8s secret field (base64-decoded).
pub fn read_secret(sh: &Shell, namespace: &str, secret_name: &str, key: &str) -> Result<String> {
    let jsonpath = format!("{{.data.{key}}}");
    let encoded = cmd_helpers::capture(
        sh,
        "kubectl",
        &[
            "get",
            "secret",
            "-n",
            namespace,
            secret_name,
            "-o",
            &format!("jsonpath={jsonpath}"),
        ],
    )
    .with_context(|| format!("reading secret {secret_name}/{key} in {namespace}"))?;

    // base64 decode
    let decoded = cmd!(sh, "base64 -d")
        .stdin(&encoded)
        .quiet()
        .ignore_stderr()
        .read()
        .with_context(|| format!("base64-decoding secret {secret_name}/{key}"))?;

    Ok(decoded.trim().to_string())
}

// -- PostgreSQL ---------------------------------------------------------------

/// Run a psql command as superuser in the PG pod.
pub fn pg_superuser_exec(
    sh: &Shell,
    cfg: &Config,
    pg_superpass: &str,
    sql: &str,
) -> Result<String> {
    let ns = &cfg.gitlab_ns;
    let pod = &cfg.pg_pod;
    let db = &cfg.pg_database;
    let bash_cmd = format!("PGPASSWORD='{pg_superpass}' psql -U postgres -d {db} -c \"{sql}\"");

    let output = cmd!(sh, "kubectl exec -n {ns} {pod} -- bash -c {bash_cmd}")
        .quiet()
        .ignore_status()
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("psql command failed: {stderr}");
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
Loading