Crea tu propio controllador GitOps con Rust


gitops-operator

Introducción

En este artículo veremos cómo utilizar Kube y Kind para crear un clúster de prueba local y un controlador usando git2 (libgit2). Luego, desplegaremos ese controlador en el clúster y lo probaremos. El repositorio con los archivos se puede encontrar aquí, y también los manifiestos.


Resumen general

El controlador simplemente automatiza la actualización de nuestro manifiesto de Kubernetes (específicamente un manifiesto de despliegue) basado en algunas anotaciones.

  • Obtener los repositorios de la aplicación y el manifiesto.
  • Actualizar el repositorio de manifiestos con el último SHA del repositorio de la aplicación.
  • Empujar los cambios.
  • Dejar que ArgoCD se encargue de aplicar el cambio.

Como puedes imaginar, hay muchas piezas en movimiento aquí y diferentes opciones. Este es el enfoque Pull de GitOps. Si deseas aprender más sobre este enfoque, puedes ir aquí.


Por otro lado, podrías preguntarte: ¿qué problema estamos intentando resolver aquí? Generalmente, cuando realizas cambios en tu aplicación,
también necesitas tener un proceso para actualizar tus manifiestos y así lanzar la última versión. Dependiendo del enfoque que tú o tu equipo prefieran,
puede ser un método push o pull. En este caso, exploraremos lo que significa que sea el método pull y también cómo ejecutarlo en Kubernetes como un controlador, (ya que estará observando las anotaciones en tus despliegues). En este caso particular, el controlador supervisará su propia aplicación y manifiestos,
algo así como una especie de Inception.


Requisitos previos

Necesitamos un clúster de Kubernetes en funcionamiento. En este caso, usaremos Kind. Ejecuta el siguiente comando para iniciar:

kind create cluster

Luego, instala ArgoCD para desplegar nuestro controlador (no es el enfoque recomendado):

helm repo add argo https://argoproj.github.io/argo-helm
helm install -n argocd argocd argo/argo-cd
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

En este punto, deberías poder realizar un port-forward y acceder a tu instancia de ArgoCD:

kubectl port-forward service/argocd-server 8080:443

Luego, debes agregar tu clave. Se verá algo así (haz clic en “Connect repo” y completa la información):
connect-repo


Podemos crear la aplicación de ArgoCD y habilitar la autocuración de la siguiente manera:
argocd

Todo debería verse algo así:
argocd-2


Puedes generar una clave RSA de la siguiente manera:

ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_demo 

Luego, en el repositorio de GitHub, necesitamos permitir que esa clave (usando la clave pública) tenga permisos de lectura y escritura en el repositorio de manifiestos (Settings → Deploy Key → Rellena el contenido y marca la casilla para otorgar permisos de escritura): github-rw


Eso fue mucha preparación. Ten en cuenta que ya tenemos una configuración de CI en su lugar para construir las imágenes y enviarlas a DockerHub. Tal vez te preguntes: ¿Por qué no simplemente quedarse con el método push y modificar el despliegue directamente en el clúster? Bueno, hay varias diferencias, pero algunas de ellas son: en la mayoría de los casos, el pipeline de CI no necesita acceso al clúster (un aspecto práctico y de seguridad; sí, puedes ejecutar el pipeline de CI desde/en el clúster usando, por ejemplo, Tekton). Otra consideración es la flexibilidad que puedes ganar para administrar el ciclo de vida de tu aplicación con solo tener un controlador con tu lógica embebida y configurar tu propio despliegue de la forma que mejor se adapte a tus necesidades. Piénsalo como un bloque de construcción de tu plataforma.


El código

Aunque esto es, por ahora, un MVP (Producto Mínimo Viable) o un proyecto experimental, hace lo mínimo necesario para cumplir su propósito. Sin embargo, podría ser frágil y no se contemplaron muchos casos por el bien de la simplicidad y porque, por lo general, eso es lo que necesitas en la primera versión de cualquier software: que funcione. Luego puede ser refactorizado, mejorado, añadir más características, pruebas (o mejores pruebas), etc., etc., etc.

Por cierto, estoy aprendiendo Rust, así que no esperes un código listo para producción ni el código más eficiente, ¡pero sí algo que funcione™!


Dockerfile

El Dockerfile es bastante sencillo. Básicamente, construimos una versión de producción, copiamos ese binario y el archivo known_hosts a la ruta home del usuario que ejecutará la imagen (esto es necesario ya que estamos utilizando autenticación SSH).

FROM clux/muslrust:stable AS builder

COPY Cargo.* .
COPY *.rs .

RUN --mount=type=cache,target=/volume/target \
    --mount=type=cache,target=/root/.cargo/registry \
    cargo build --release --bin gitops-operator && \
    mv /volume/target/x86_64-unknown-linux-musl/release/gitops-operator .

FROM cgr.dev/chainguard/static

COPY --from=builder --chown=nonroot:nonroot /volume/gitops-operator /app/
COPY files/known_hosts /home/nonroot/.ssh/known_hosts

EXPOSE 8080

ENTRYPOINT ["/app/gitops-operator"]

A continuación, podemos revisar el archivo known_hosts. Estas firmas fueron tomadas de aquí,
pero también puedes hacerlo usando el comando ssh-keygen.

github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl
github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=
github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=

El código principal

Este archivo es el main.rs, el cual Cargo buscará (ya que lo configuré de esa manera) para construir nuestra aplicación. En resumen, configurará un servidor HTTP en el puerto 8000 con dos rutas: /health y /reconcile. En lugar de configurar un método adecuado de reconciliación (que normalmente sería una función recursiva o utilizando un callback), decidí usar la Readiness Probe para desencadenar una llamada al endpoint /reconcile y hacer que se ejecute cada dos minutos (lo revisaremos cuando lleguemos a los manifiestos).

pub mod files;
pub mod git;

use axum::extract::State;
use axum::{routing, Json, Router};
use futures::{future, StreamExt};
use k8s_openapi::api::apps::v1::Deployment;
use kube::runtime::{reflector, watcher, WatchStreamExt};
use kube::{Api, Client, ResourceExt};
use std::collections::BTreeMap;
use tracing::{debug, warn};

use files::patch_deployment_and_commit;
use git::clone_repo;

#[derive(serde::Serialize, Clone)]
struct Config {
    enabled: bool,
    namespace: String,
    app_repository: String,
    manifest_repository: String,
    image_name: String,
    deployment_path: String,
}

#[derive(serde::Serialize, Clone)]
struct Entry {
    container: String,
    name: String,
    namespace: String,
    annotations: BTreeMap<String, String>,
    version: String,
    config: Config,
}
type Cache = reflector::Store<Deployment>;

fn deployment_to_entry(d: &Deployment) -> Option<Entry> {
    let name = d.name_any();
    let namespace = d.namespace()?;
    let annotations = d.metadata.annotations.as_ref()?;
    let tpl = d.spec.as_ref()?.template.spec.as_ref()?;
    let img = tpl.containers.get(0)?.image.as_ref()?;
    let splits = img.splitn(2, ':').collect::<Vec<_>>();
    let (container, version) = match *splits.as_slice() {
        [c, v] => (c.to_owned(), v.to_owned()),
        [c] => (c.to_owned(), "latest".to_owned()),
        _ => return None,
    };

    let enabled = annotations.get("gitops.operator.enabled")?.trim().parse().unwrap();
    let app_repository = annotations.get("gitops.operator.app_repository")?.to_string();
    let manifest_repository = annotations.get("gitops.operator.manifest_repository")?.to_string();
    let image_name = annotations.get("gitops.operator.image_name")?.to_string();
    let deployment_path = annotations.get("gitops.operator.deployment_path")?.to_string();

    println!("Processing: {}/{}", &namespace, &name);

    Some(Entry {
        name,
        namespace: namespace.clone(),
        annotations: annotations.clone(),
        container,
        version,
        config: Config {
            enabled,
            namespace: namespace.clone(),
            app_repository,
            manifest_repository,
            image_name,
            deployment_path,
        },
    })
}

// - GET /reconcile
async fn reconcile(State(store): State<Cache>) -> Json<Vec<Entry>> {
    let data: Vec<_> = store.state().iter().filter_map(|d| deployment_to_entry(d)).collect();

    for entry in &data {
        if !entry.config.enabled {
            println!("continue");
            continue;
        }

        // Perform reconciliation
        let app_local_path = format!("/tmp/app-{}", &entry.name);
        let manifest_local_path = format!("/tmp/manifest-{}", &entry.name);

        clone_repo(&entry.config.app_repository, &app_local_path);
        clone_repo(&entry.config.manifest_repository, &manifest_local_path);
        let _ = patch_deployment_and_commit(
            format!("/tmp/app-{}", &entry.name).as_ref(),
            format!("/tmp/manifest-{}", &entry.name).as_ref(),
            &entry.config.deployment_path,
            &entry.config.image_name,
        );
    }

    Json(data)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    println!("Starting gitops-operator");

    tracing_subscriber::fmt::init();
    let client = Client::try_default().await?;
    let api: Api<Deployment> = Api::all(client);

    let (reader, writer) = reflector::store();
    let watch = reflector(writer, watcher(api, Default::default()))
        .default_backoff()
        .touched_objects()
        .for_each(|r| {
            future::ready(match r {
                Ok(o) => debug!("Saw {} in {}", o.name_any(), o.namespace().unwrap()),
                Err(e) => warn!("watcher error: {e}"),
            })
        });
    tokio::spawn(watch); // poll forever

    let app = Router::new()
        .route("/reconcile", routing::get(reconcile))
        .with_state(reader) // routes can read from the reflector store
        .layer(tower_http::trace::TraceLayer::new_for_http())
        // NB: routes added after TraceLayer are not traced
        .route("/health", routing::get(|| async { "up" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await?;
    axum::serve(listener, app.into_make_service()).await?;

    Ok(())
}

En este archivo files.rs tenemos las funciones relevantes a cambios en archivos, mas especificamente el archivo de deployment.

use crate::git::stage_and_push_changes;
use anyhow::Context;
use anyhow::Error;
use git2::Error as GitError;
use git2::Repository;
use k8s_openapi::api::apps::v1::Deployment;
use serde_yaml;
use std::fs;

fn patch_image_tag(file_path: String, image_name: String, new_sha: String) -> Result<(), Error> {
    println!("Patching image tag in deployment file: {}", file_path);
    let yaml_content = fs::read_to_string(&file_path).context("Failed to read deployment YAML file")?;

    println!("before: {:?}", yaml_content);

    // Parse the YAML into a Deployment resource
    let mut deployment: Deployment =
        serde_yaml::from_str(&yaml_content).context("Failed to parse YAML into Kubernetes Deployment")?;

    // Modify deployment specifics
    if let Some(spec) = deployment.spec.as_mut() {
        if let Some(template) = spec.template.spec.as_mut() {
            for container in &mut template.containers {
                if container.image.as_ref().unwrap().contains(&new_sha) {
                    println!("Image tag already updated... Aborting mission!");
                    return Err(anyhow::anyhow!("Image tag {} is already up to date", new_sha));
                }
                if container.image.as_ref().unwrap().contains(&image_name) {
                    container.image = Some(format!("{}:{}", &image_name, &new_sha));
                }
            }
        }
    }

    // Optional: Write modified deployment back to YAML file
    let updated_yaml =
        serde_yaml::to_string(&deployment).context("Failed to serialize updated deployment")?;

    println!("updated yaml: {:?}", updated_yaml);

    fs::write(file_path, updated_yaml).context("Failed to write updated YAML back to file")?;

    Ok(())
}

pub fn patch_deployment_and_commit(
    app_repo_path: &str,
    manifest_repo_path: &str,
    file_name: &str,
    image_name: &str,
) -> Result<(), GitError> {
    println!("Patching deployment and committing changes");
    let commit_message = "chore(refs): gitops-operator updating image tags";
    let app_repo = Repository::open(&app_repo_path)?;
    let manifest_repo = Repository::open(&manifest_repo_path)?;

    // Find the latest remote head
    // While this worked, it failed in some scenarios that were unimplemented
    // let new_sha = app_repo.head()?.peel_to_commit().unwrap().parent(1)?.id().to_string();

    let fetch_head = app_repo.find_reference("FETCH_HEAD")?;
    let remote = app_repo.reference_to_annotated_commit(&fetch_head)?;
    let remote_commit = app_repo.find_commit(remote.id())?;

    let new_sha = remote_commit.id().to_string();

    println!("New application SHA: {}", new_sha);

    // Perform changes
    let patch = patch_image_tag(
        format!("{}/{}", manifest_repo_path, file_name),
        image_name.to_string(),
        new_sha,
    );

    match patch {
        Ok(_) => println!("Image tag updated successfully"),
        Err(e) => {
            println!("We don't need to update image tag: {:?}", e);
            return Err(GitError::from_str(
                "Aborting update image tag, already updated...",
            ));
        }
    }

    // Stage and push changes
    let _ = stage_and_push_changes(&manifest_repo, commit_message)?;

    Ok(())
}

Y por ultimo pero no menos importante git.rs, posiblemente la parte mas compleja de este proyecto, git… Este archivo tiene algunas funciones que se encargan de clonar, traer cambios remotos y fusionarlos, tambien para agregar los cambios locales y enviarlos al repositorio remoto.

use git2::{
    build::RepoBuilder, CertificateCheckStatus, Cred, Error as GitError, FetchOptions, RemoteCallbacks,
    Repository,
};
use std::env;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use git2::Signature;
use std::time::{SystemTime, UNIX_EPOCH};

fn create_signature<'a>() -> Result<Signature<'a>, GitError> {
    let name = "GitOps Operator";
    let email = "[email protected]";

    // Get current timestamp
    let time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();

    // Create signature with current timestamp
    Signature::new(name, email, &git2::Time::new(time as i64, 0))
}

fn normal_merge(
    repo: &Repository,
    local: &git2::AnnotatedCommit,
    remote: &git2::AnnotatedCommit,
) -> Result<(), git2::Error> {
    let local_tree = repo.find_commit(local.id())?.tree()?;
    let remote_tree = repo.find_commit(remote.id())?.tree()?;
    let ancestor = repo.find_commit(repo.merge_base(local.id(), remote.id())?)?.tree()?;
    let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;

    if idx.has_conflicts() {
        println!("Merge conflicts detected...");
        repo.checkout_index(Some(&mut idx), None)?;
        return Ok(());
    }
    let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
    // now create the merge commit
    let msg = format!("Merge: {} into {}", remote.id(), local.id());
    let sig = repo.signature()?;
    let local_commit = repo.find_commit(local.id())?;
    let remote_commit = repo.find_commit(remote.id())?;
    // Do our merge commit and set current branch head to that commit.
    let _merge_commit = repo.commit(
        Some("HEAD"),
        &sig,
        &sig,
        &msg,
        &result_tree,
        &[&local_commit, &remote_commit],
    )?;
    // Set working tree to match head.
    repo.checkout_head(None)?;
    Ok(())
}

pub fn clone_or_update_repo(url: &str, repo_path: PathBuf) -> Result<(), GitError> {
    println!("Cloning or updating repository from: {}", &url);

    // Setup SSH key authentication
    let mut callbacks = RemoteCallbacks::new();
    callbacks.credentials(|_url, username_from_url, _allowed_types| {
        // Dynamically find SSH key path
        let ssh_key_path = format!(
            "{}/.ssh/id_rsa_demo",
            env::var("HOME").expect("HOME environment variable not set")
        );

        println!("Using SSH key: {}", &ssh_key_path);
        println!("{}", Path::new(&ssh_key_path).exists());

        Cred::ssh_key(
            username_from_url.unwrap_or("git"),
            None,
            Path::new(&ssh_key_path),
            None,
        )
    });

    // TODO: implement certificate check, potentially insecure
    callbacks.certificate_check(|_cert, _host| {
        // Return true to indicate we accept the host
        Ok(CertificateCheckStatus::CertificateOk)
    });

    // Prepare fetch options
    let mut fetch_options = FetchOptions::new();
    fetch_options.remote_callbacks(callbacks);
    fetch_options.download_tags(git2::AutotagOption::All);

    // Check if repository already exists
    if repo_path.exists() {
        println!("Repository already exists, pulling...");

        // Open existing repository
        let repo = Repository::open(&repo_path)?;

        // Fetch changes
        fetch_existing_repo(&repo, &mut fetch_options)?;

        // Pull changes (merge)
        pull_repo(&repo, &fetch_options)?;
    } else {
        println!("Repository does not exist, cloning...");

        // Clone new repository
        clone_new_repo(url, &repo_path, fetch_options)?;
    }

    Ok(())
}

/// Fetch changes for an existing repository
fn fetch_existing_repo(repo: &Repository, fetch_options: &mut FetchOptions) -> Result<(), GitError> {
    println!("Fetching changes for existing repository");

    // Find the origin remote
    let mut remote = repo.find_remote("origin")?;

    // Fetch all branches
    let refs = &["refs/heads/master:refs/remotes/origin/master"];

    remote.fetch(refs, Some(fetch_options), None)?;

    Ok(())
}

/// Clone a new repository
fn clone_new_repo(url: &str, local_path: &Path, fetch_options: FetchOptions) -> Result<Repository, GitError> {
    println!("Cloning repository from: {}", &url);
    // Prepare repository builder
    let mut repo_builder = RepoBuilder::new();
    repo_builder.fetch_options(fetch_options);

    // Clone the repository
    repo_builder.clone(url, local_path)
}

/// Pull (merge) changes into the current branch
fn pull_repo(repo: &Repository, _fetch_options: &FetchOptions) -> Result<(), GitError> {
    println!("Pulling changes into the current branch");

    // Find remote branch
    let remote_branch_name = format!("remotes/origin/master");

    println!("Merging changes from remote branch: {}", &remote_branch_name);

    // Annotated commit for merge
    let fetch_head = repo.find_reference("FETCH_HEAD")?;
    let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;

    // Perform merge analysis
    let (merge_analysis, _) = repo.merge_analysis(&[&fetch_commit])?;

    println!("Merge analysis result: {:?}", merge_analysis);

    if merge_analysis.is_fast_forward() {
        let refname = format!("refs/remotes/origin/master");
        let mut reference = repo.find_reference(&refname)?;
        reference.set_target(fetch_commit.id(), "Fast-Forward")?;
        repo.set_head(&refname)?;
        let _ = repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()));

        Ok(())
    } else if merge_analysis.is_normal() {
        let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
        normal_merge(&repo, &head_commit, &fetch_commit)?;

        Ok(())
    } else if merge_analysis.is_up_to_date() {
        println!("Repository is up to date");
        Ok(())
    } else {
        Err(GitError::from_str("Unsupported merge analysis case"))
    }
}

pub fn stage_and_push_changes(repo: &Repository, commit_message: &str) -> Result<(), GitError> {
    println!("Staging and pushing changes");

    // Stage all changes (equivalent to git add .)
    let mut index = repo.index()?;
    index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
    index.write()?;

    // Create a tree from the index
    let tree_id = index.write_tree()?;
    let tree = repo.find_tree(tree_id)?;

    // Get the current head commit
    let parent_commit = repo.head()?.peel_to_commit()?;

    println!("Parent commit: {}", parent_commit.id());

    // Prepare signature (author and committer)
    // let signature = repo.signature()?;
    let signature = create_signature()?;

    println!("Author: {}", signature.name().unwrap());

    // Create the commit
    let commit_oid = repo.commit(
        Some("HEAD"),      // Update HEAD reference
        &signature,        // Author
        &signature,        // Committer
        commit_message,    // Commit message
        &tree,             // Tree to commit
        &[&parent_commit], // Parent commit
    )?;

    println!("New commit: {}", commit_oid);

    // Prepare push credentials
    let mut callbacks = RemoteCallbacks::new();
    callbacks.credentials(|_url, username_from_url, _allowed_types| {
        // Dynamically find SSH key path
        let ssh_key_path = format!(
            "{}/.ssh/id_rsa_demo",
            env::var("HOME").expect("HOME environment variable not set")
        );

        println!("Using SSH key: {}", &ssh_key_path);
        println!("{}", Path::new(&ssh_key_path).exists());

        Cred::ssh_key(
            username_from_url.unwrap_or("git"),
            None,
            Path::new(&ssh_key_path),
            None,
        )
    });

    // TODO: implement certificate check, potentially insecure
    callbacks.certificate_check(|_cert, _host| {
        // Return true to indicate we accept the host
        Ok(CertificateCheckStatus::CertificateOk)
    });

    // Print out our transfer progress.
    callbacks.transfer_progress(|stats| {
        if stats.received_objects() == stats.total_objects() {
            print!(
                "Resolving deltas {}/{}\r",
                stats.indexed_deltas(),
                stats.total_deltas()
            );
        } else if stats.total_objects() > 0 {
            print!(
                "Received {}/{} objects ({}) in {} bytes\r",
                stats.received_objects(),
                stats.total_objects(),
                stats.indexed_objects(),
                stats.received_bytes()
            );
        }
        io::stdout().flush().unwrap();
        true
    });

    // Prepare push options
    let mut push_options = git2::PushOptions::new();
    push_options.remote_callbacks(callbacks);

    // Find the origin remote
    let mut remote = repo.find_remote("origin")?;

    println!("Pushing to remote: {}", remote.url().unwrap());

    // Determine the current branch name
    let branch_name = repo.head()?;
    let refspec = format!("refs/heads/{}", branch_name.shorthand().unwrap_or("master"));

    println!("Pushing to remote branch: {}", &refspec);

    // Push changes
    remote.push(&[&refspec], Some(&mut push_options))?;

    Ok(())
}

// Example usage in the context of the original code
pub fn clone_repo(url: &str, local_path: &str) {
    let repo_path = PathBuf::from(local_path);

    match clone_or_update_repo(url, repo_path) {
        Ok(_) => println!("Repository successfully updated"),
        Err(e) => eprintln!("Error updating repository: {}", e),
    }
}

Algunas cosas que necesito mejorar en todo ese código son:

  • Manejo de errores
  • Estandarizar todas las funciones para que se comporten de manera similar
  • Estandarizar los logs y eliminar toda la información de depuración
  • Refactorizar las funciones para que sean más específicas y manejen una sola responsabilidad

Y algunas cosas más, pero recordá que esto es un MVP; por ahora, lo único que importa es que funcione.


El pipeline

Para completar el panorama, agreguemos la pipeline. Como podés ver, tenemos dos jobs: uno para lint y validar que el código esté correctamente formateado, y otro para compilar y publicar la imagen en DockerHub. Lo más interesante probablemente sea el “baile del caché”.

name: ci

on:
  pull_request:
  push:
    branches:
      - master
    tags:
      - '*'

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false
      matrix:
        platform:
          - linux/amd64
          #- linux/arm64
    steps:
      - uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      # Build and push with docker buildx
      - name: Setup docker buildx
        uses: docker/setup-buildx-action@v3
        with:
          config: .github/buildkitd.toml

      - name: Configure tags based on git tags + latest
        uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ github.repository }}
          tags: |
            type=sha,prefix=,suffix=,format=short
            type=sha,prefix=,suffix=,format=long
            type=ref,event=branch
            type=pep440,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}
            type=ref,event=pr

      - name: Rust Build Cache for Docker
        uses: actions/cache@v4
        with:
          path: rust-build-cache
          key: ${{ runner.os }}-build-cache-${{ hashFiles('**/Cargo.toml') }}

      - name: inject rust-build-cache into docker
        uses: overmindtech/buildkit-cache-dance/inject@main
        with:
          cache-source: rust-build-cache

      - name: Docker login
        uses: docker/login-action@v3
        #if: github.event_name != 'pull_request'
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Docker build and push with cache
        uses: docker/build-push-action@v6
        with:
          context: .
          # when not using buildkit cache
          #cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          #cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          # when using buildkit-cache-dance
          cache-from: type=gha
          cache-to: type=gha,mode=max
          #push: ${{ github.ref == 'refs/heads/main' }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          platforms: ${{ matrix.platform }}

      - name: extract rust-build-cache from docker
        uses: overmindtech/buildkit-cache-dance/extract@main
        with:
          cache-source: rust-build-cache

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: stable
          components: rustfmt,clippy
      - run: cargo fmt -- --check

      - uses: giraffate/clippy-action@v1
        with:
          reporter: 'github-pr-review'
          github_token: ${{ secrets.GITHUB_TOKEN }}

Cada vez que realicemos un cambio en la aplicación, veremos automáticamente un commit del controlador, algo así: commits


Manifiestos

Una de las cosas que necesitamos hacer para que esto funcione es almacenar nuestra clave SSH como un secreto para poder montarla (podríamos haber leído el secreto directamente desde el controlador usando kube-rs, pero este enfoque era más sencillo). Como notarás, tenemos un conjunto de anotaciones para configurar el controlador y que actúe sobre nuestro Deployment, y luego montamos la clave SSH que generamos previamente para que pueda obtener y escribir en el repositorio de manifiestos.

Además, verás que estamos llamando a la ruta /reconcile cada dos minutos desde el Readiness Probe. Kubernetes seguirá llamando a ese endpoint cada dos minutos basándose en nuestra configuración. Como no esperamos tráfico externo, esto debería ser suficiente. Además, debería ser relativamente seguro ejecutarlo de forma concurrente, por lo que es una solución adecuada por ahora.

apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    gitops.operator.app_repository: git@github.com:kainlite/gitops-operator.git
    gitops.operator.deployment_path: app/00-deployment.yaml
    gitops.operator.enabled: 'true'
    gitops.operator.image_name: kainlite/gitops-operator
    gitops.operator.manifest_repository: git@github.com:kainlite/gitops-operator-manifests.git
    gitops.operator.namespace: default
  labels:
    app: gitops-operator
  name: gitops-operator
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gitops-operator
  template:
    metadata:
      labels:
        app: gitops-operator
    spec:
      containers:
      - image: kainlite/gitops-operator:ab76bb8f5064a6df5ed54ae68b0f0c6eaa6dcbb6
        imagePullPolicy: Always
        livenessProbe:
          failureThreshold: 5
          httpGet:
            path: /health
            port: http
          periodSeconds: 15
        name: gitops-operator
        ports:
        - containerPort: 8000
          name: http
          protocol: TCP
        readinessProbe:
          httpGet:
            path: /reconcile
            port: http
          initialDelaySeconds: 60
          periodSeconds: 120
          timeoutSeconds: 60
        resources:
          limits:
            cpu: 1000m
            memory: 1024Mi
          requests:
            cpu: 500m
            memory: 100Mi
        volumeMounts:
        - mountPath: /home/nonroot/.ssh/id_rsa_demo
          name: my-ssh-key
          readOnly: true
          subPath: ssh-privatekey
      serviceAccountName: gitops-operator
      volumes:
      - name: my-ssh-key
        secret:
          items:
          - key: ssh-privatekey
            path: ssh-privatekey
          secretName: my-ssh-key

¡BONUS! Si no recordás cómo crear una clave y almacenarla como un secreto en Kubernetes, es realmente simple. Si necesitás ayuda o querés una referencia rápida sobre las formas más comunes de crear y usar secretos en Kubernetes, te recomiendo mi artículo al respecto:


ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_demo
kubectl create secret generic my-ssh-key --from-file=ssh-privatekey=~/.ssh/id_rsa_demo

¡Y listo! Pronto estaré subiendo una versión en video, seguime en LinkedIn para mantenerte al tanto de las novedades.

Notas finales

Esto estuvo fuertemente inspirado y basado en los ejemplos de kube-rs: version-rs, así como en muchos ejemplos del repositorio de git2. Si te gustó, seguime y compartilo para más contenido como este. ¡Nos vemos en la próxima! 🚀


No tienes cuenta? Regístrate aqui

Ya registrado? Iniciar sesión a tu cuenta ahora.

Iniciar session con GitHub
Iniciar sesion con Google
  • Comentarios

    Online: 0

Por favor inicie sesión para poder escribir comentarios.

by Gabriel Garrido