Devbox: Ambientes De Desarrollo Portables Y Aislados

El manejador de dependencias que me gusta más para ambientes de desarrollo.

Lastmod: 2025-04-02

El manejador de dependencias que me gusta más para ambientes de desarrollo.

Tabla de Contenidos

Introducción

Soy de esas personas que necesitan tener todo en su sitio para poder hacer algo. Esto aplica, por supuesto, cuando trabajo con un repositorio local o remoto. Necesito tener todas las dependencias instaladas y configuradas; más aún, el entorno debe estar preparado con todas las herramientas y con la señalización mostrando el estado actual del ambiente.

Con señalización me refiero al “prompt” de la consola, que debe ser lo más amigable y estético posible. Para muestra, un botón:

Home Shell
Home Shell

La terminal es iTerm, corriendo zsh por defecto, con la extensión Presto , la cual me permite configurar plugins que mejoran la búsqueda de comandos en el histórico, mejoran la interacción con Git, y un largo etcétera. Quien esté interesado puede revisar sus características siguiendo el enlace.

En un directorio de desarrollo, la parte visual debería mostrar la ruta actual, el branch en el que me encuentro, y si estoy dentro de un ambiente especial como un virtualenv, etc. Por ejemplo, el directorio donde desarrollo este blog:

Development Shell
Development Shell

El lector conocedor habrá de percatarse que estoy usando un entorno Nix y un multiplexador de terminal (Zellij).

Nix

Nix es un ecosistema de varios proyectos de código abierto soportados por su comunidad.

Los más relevantes en mi opinión son:

  • NixOS: Una distribución de GNU/Linux completa.
  • nix-shell: Es una shell Ad Hoc que posibilita instalar paquetes existentes en los repositorios de Nix. Éstos paquetes se descargan en un repo Nix que reside en una ruta local.
  • Lenguaje Nix: Un lenguaje shell de programación propio que permite manipular los ambientes Nix a voluntad.

El entorno nix-shell puede instalarse tanto en MacOS, WSL (Windows) y GNU/Linux.

nix-shell funciona creando un área aislada en el ambiente del usuario donde un repositorio local se encarga de gestionar los paquetes Nix instalados por el usuario.

La shell de Nix es genial, pero tiene un par de inconvenientes. El repositorio que crea es global para el usuario; todos los paquetes instalados se almacenan en un solo lugar. Por lo tanto, al momento de limpiar dependencias, puede ser un tanto complicado. El segundo problema es que para manejarlo eficientemente hay que aprender el lenguaje que utiliza, y es bastante complejo. No todos desearán invertir tiempo aprendiendo un lenguaje solo para crear sus ambientes de desarrollo.

Hay varias alternativas basadas en Nix, pero la que me parece mejor, por lo menos para el uso que le doy, es Devbox.

Devbox

Devbox es similar a Nix en todo sentido (lo usa por detrás), excepto que genera repositorios por proyecto y su definición es mucho más sencilla, utilizando un archivo estructurado en JSON llamado devbox.json, el cual permite ser compartido en el repositorio y vuelve el ambiente reproducible y automatizable.

Crearemos un ejemplo muy simple que puede ser encontrado en este repositorio https://github.com/Walsen/devbox-basics

Hemos de asumir que nos encontramos en un sistema compatible con Unix y que tiene una shell similar a bash en la terminal.

Paso 0: Instalar Devbox

Es tan sencillo como ejecutar un comando shell (Linux & MacOS):

curl -fsSL https://get.jetify.com/devbox | bash

Luego de esto, Devbox ya estará disponible para ser usado.

Paso 1: Crear el directorio

Creamos un directorio simple con el comando mkdir e ingresamos:

mkdir devbox-basics
cd devbox-basics

Paso 2: Generar el archivo ‘devbox.json’ y revisar su contenido

Devbox puede generar su archivo de configuración automáticamente:

devbox init
cat devbox.json

Devbox file
Devbox file

Podemos identificar tres secciones principales:

  • $schema: La definición del documento según la versión especificada en la URL.
  • packages: Un array con la lista de paquetes instalados para éste repositorio y sus dependencias.
  • shell: Un objeto que contiene otros objetos que nos ayudan automatizar el ambiente, ya sea al momento de iniciarlo o a demanda.
    • init_hook: Un array con una lista de comandos que se ejecutarán al iniciar el ambiente.
    • scripts: Un objeto que contiene pares de llave/valor donde el valor es un array que puede contener comandos shell que se ejecutarán cuando la llave sea invocada usando el comando devbox run [key].

Exísten secciones adicionales: env, env_from e include. Las dos primeras se usan para inicializar variables de ambiente, ya sea explícitamente especificadas en el devbox.json o leídas desde un archivo .env. La última sirve para incluir plugins adicionales de Devbox.

Paso 3: Agregar paquetes / dependencias

Generalmente es fácil ubicar los paquetes que queremos instalar solo por su nombre común; por ejemplo, agregaremos una lista de paquetes que uso comúnmente:

devbox add git direnv awscli2 python@3.12 terraform just zellij pipenv

Como el lector podrá apreciar, es posible especificar versiones utilizando el signo de arroba después del nombre del paquete, seguido de la versión. Para ver qué versiones están disponibles o buscar el paquete correcto, esta página está disponible https://search.nixos.org/packages .

Devbox Add
Devbox Add

Nix no provee binarios de algunos paquetes, por lo tanto, debe compilar el código; es lo que hizo con Terraform en la instalación.

Finalmente, Devbox también provee configuraciones listas para usar (out-of-the-box) para lenguajes como Python, para el cual ofrece un entorno virtual funcional. En mi caso elegí Pipenv, por lo que me tomará un par de pasos más tenerlo listo.

Hasta ahora nuestro devbox.json luce así:

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "direnv@latest",
    "awscli2@latest",
    "python@3.12",
    "terraform@latest",
    "just@latest",
    "zellij@latest",
    "pipenv@latest"
  ],
  "shell": {
    "init_hook": [
      "echo 'Welcome to devbox!' > /dev/null"
    ],
    "scripts": {
      "test": [
        "echo \"Error: no test specified\" && exit 1"
      ]
    }
  }
}

Paso 4: Iniciar la Shell

Para iniciar la shell de Devbox simplemente ejecutamos el siguiente comando:

devbox shell

La pantalla resultante debería ser similar a esta:

Devbox shell
Devbox shell

Podemos notar que el “Prompt” ha cambiado indicando que estamos dentro de la shell de Devbox.

Verificamos las dependencias y probemos el virtualenv:

Devbox pipenv install
Devbox pipenv install

A partir de aquí ya podemos desarrollar ya sea en Terraform y/o Python sin problemas.

Paso 5: Configurar el ambiente usando direnv

direnv es una pequeña utilidad que automatiza el uso de variables de entorno; simplemente declaramos las variables en un archivo .envrc y direnv las cargará automáticamente cuando entremos en la carpeta donde reside el .envrc.

Devbox viene con soporte para direnv. Para generar un archivo de configuración, ejecutamos el siguiente comando desde fuera de la shell:

devbox generate direnv

Devbox enable direnv
Devbox enable direnv

Al momento de ejecutar el comando, Devbox automáticamente lo genera y ejecuta la shell con las configuraciones del .envrc. Un detalle interesante es que de esta manera el “prompt” de mi consola ha sido importado a la shell de Devbox, lo cual es muy conveniente.

Ya con direnv cargado, podemos empezar a exportar variables como, por ejemplo, los access keys de nuestro usuario de AWS.

Editamos el archivo .envrc y al final añadimos las variables:

#!/bin/bash

# Automatically sets up your devbox environment whenever you cd into this
# directory via our direnv integration:

eval "$(devbox generate direnv --print-envrc)"

# check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/
# for more details

export AWS_ACCESS_KEY_ID="ASIA4RRMHHCFXBM0DKRM"
export AWS_SECRET_ACCESS_KEY="4pqlKkaQNBlueLLHkNiWpgDNzihdNfgTYjy3tYk0w"

Y corremos el comando en el mismo directorio:

direnv allow

Cada vez que se realiza un cambio en el .envrc, es necesario autorizar el cambio, lo cual hará un “reload” del ambiente:

Devbox export variables
Devbox export variables

Finalmente probamos los access keys listando nuestros buckets en S3.

🚨 ¡ADVERTENCIA! 🚨

NUNCA pero NUNCA se deben enviar credenciales o cualquier texto sensible al repositorio. Por lo tanto, una buena práctica sería dejar el valor de los access keys en blanco, enviar el .envrc al repo, agregarlo al .gitignore después y así ya no sería tomado en cuenta para futuros envíos. Los demás miembros del equipo podrían usar sus credenciales personales sin temor a enviarlas luego.

Paso 6: Implementar automatización

Para probar las características de automatización de Devbox, agregaremos un simple script en Python que nos muestra los buckets en S3 replicando el comando aws s3 ls.

El código sería el siguiente:

#!/usr/bin/env python3

import boto3
from botocore.exceptions import ClientError

def list_s3_buckets():
    """
    List all S3 buckets in the AWS account
    """
    try:
        # Create an S3 client
        s3_client = boto3.client('s3')
        
        # Get list of buckets
        response = s3_client.list_buckets()
        
        print("S3 Buckets:")
        print("-----------")
        
        # Print bucket names
        for bucket in response['Buckets']:
            print(f"- {bucket['Name']}")
            
    except ClientError as e:
        print(f"Error: {e}")
        return None

if __name__ == "__main__":
    list_s3_buckets()

Si ejecutamos el código, vamos a obtener un mensaje de error:

Devbox virtualenv error
Devbox virtualenv error

Obviamente porque no hemos activado el virtualenv de Pipenv.

Para evitar la fatiga de hacerlo cada vez que ingresemos al ambiente, automatizaremos la tarea modificando el devbox.json de la siguiente manera:

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "direnv@latest",
    "awscli2@latest",
    "python@3.12",
    "terraform@latest",
    "just@latest",
    "zellij@latest",
    "pipenv@latest"
  ],
  "env_from": ".env",
  "shell": {
    "init_hook": [
      "echo 'Welcome to devbox!'",
      "pipenv shell"
    ],
    "scripts": {
      "test": [
        "echo \"Error: no test specified\" && exit 1"
      ]
    }
  }
}

Variables de ambiente en una shell virtualenv

Hemos agregado un nuevo atributo: env_from, el cual lee un archivo .env en la ruta especificada y carga las variables de entorno. El lector se preguntará por qué hacemos esto si ya tenemos direnv exportando variables para nuestro ambiente Devbox; hay que tener en cuenta que cuando se activa el virtualenv de Python, se crea una nueva shell con sus propias variables de ambiente. Para poder exportar nuestros access keys al virtualenv, tenemos que hacerlo usando esta característica.

Existe también la posibilidad de agregar las variables directamente en el devbox.json usando el bloque env, pero queremos agregar nuestros access keys directamente. Entonces, un archivo .env lo podemos enviar vacío y que cada quien agregue las variables de su ambiente.

Entonces, nuestro archivo .env contiene:

AWS_ACCESS_KEY_ID="ASIA4RRMHHCFXBM0DKRM"
AWS_SECRET_ACCESS_KEY="4pqlKkaQNBlueLLHkNiWpgDNzihdNfgTYjy3tYk0w"

Comandos en el init_hook

Hemos agregado el comando pipenv shell como segundo valor en la lista init_hook de tal manera que, cuando inicie la shell Devbox, inicie el virtualenv de Pipenv a su vez. El resultado debería ser similar a este:

Devbox init hook
Devbox init hook

Y si corremos nuestro script vemos que funciona perfectamente.

Algo en lo que sigo trabajando es en actualizar el prompt, respetando el de la shell original o cargar uno nuevo, pero como el script activate del virtualenv ejecuta una shell bash, no hay mucho que hacer a menos que parchemos este archivo, pero esto no sería portable, lamentablemente.

Si no nos importa ejecutar un simple comando como pipenv shell a mano, entonces el prompt respetará el actual y evitamos esta automatización, ya que, además, si trabajamos con Terraform en el mismo repositorio, puede que no deseemos estar en el virtualenv todo el tiempo. De cualquier manera, este es solo un ejemplo de lo que podemos hacer.

Scripts

Devbox nos permite ejecutar scripts usando el entorno sin necesidad de activar la shell; por ejemplo, podemos ejecutar el script que lista buckets modificando el devbox.json de la siguiente manera:

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "direnv@latest",
    "awscli2@latest",
    "python@3.12",
    "terraform@latest",
    "just@latest",
    "zellij@latest",
    "pipenv@latest"
  ],
  "env_from": ".env",
  "shell": {
    "init_hook": [
      "echo 'Welcome to devbox!'"
    ],
    "scripts": {
      "getbuckets": "pipenv run python getbuckets.py"
    }
  }
}

Ahora simplemente necesitamos invocar el key getbuckets en el comando devbox run ...:

Devbox shell run
Devbox shell run

Devbox va a levantar temporalmente la shell, cargar las variables y ejecutará pipenv run, el cual a su vez ejecuta python getbuckets.py en su entorno de Python.

Debemos enfatizar que el comando devbox run se ejecuta cuando la shell de Devbox no ha sido activada; si tenemos direnv configurado para cargar el ambiente automaticamente entonces no funcionará. Para deshabilitar la carga automatica de la shell de Devbox simplemente ejecutamos:

direnv disallow

Y la shell ya no se cargará al entrar al directorio.

Integración con herramientas y automatización avanzada

Devbox nos provee listas para encadenar comandos al iniciar el ambiente o al ejecutar scripts, pero no solo podemos ejecutar comandos planos. Podemos aprovechar herramientas livianas de ejecución de tareas y flujos de trabajo, como ser:

Para demostrar este punto, vamos a crear un conjunto de scripts en Terraform para levantar una instancia EC2.

main.tf

provider "aws" {
  region = var.aws_region
}

resource "aws_instance" "app_server" {
  ami           = data.aws_ami.amazon_linux_2.id
  instance_type = var.instance_type

  subnet_id                   = aws_subnet.main.id
  vpc_security_group_ids      = [aws_security_group.instance_sg.id]
  associate_public_ip_address = true

  tags = {
    Name = var.instance_name
  }
}

# Get latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

variables.tf

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

variable "instance_name" {
  description = "Value of the Name tag for the EC2 instance"
  type        = string
  default     = "AppServer"
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "subnet_cidr" {
  description = "CIDR block for subnet"
  type        = string
  default     = "10.0.1.0/24"
}

network.tf

# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "main"
  }
}

# Public Subnet
resource "aws_subnet" "main" {
  vpc_id                  = aws_vpc.main.id
  cidr_block             = var.subnet_cidr
  map_public_ip_on_launch = true
  availability_zone       = "${var.aws_region}a"

  tags = {
    Name = "Main"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "Main"
  }
}

# Route Table
resource "aws_route_table" "main" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "Main"
  }
}

# Route Table Association
resource "aws_route_table_association" "main" {
  subnet_id      = aws_subnet.main.id
  route_table_id = aws_route_table.main.id
}

security.tf

resource "aws_security_group" "instance_sg" {
  name        = "instance_sg"
  description = "Security group for EC2 instance"
  vpc_id      = aws_vpc.main.id

  # SSH access
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  # Outbound internet access
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "instance_sg"
  }
}

outputs.tf

output "instance_id" {
  description = "ID of the EC2 instance"
  value       = aws_instance.app_server.id
}

output "instance_public_ip" {
  description = "Public IP address of the EC2 instance"
  value       = aws_instance.app_server.public_ip
}

output "instance_public_dns" {
  description = "Public DNS name of the EC2 instance"
  value       = aws_instance.app_server.public_dns
}

Ahora que tenemos el script en Terraform, creamos un pequeño build script: justfile, para ejecutar los comandos de Terraform. Además, incluiremos una tarea para correr nuestro script para recuperar buckets en Python:

justfile

# Set the default recipe to list available recipes
default:
    @just --list

# Initialize Terraform
init:
    terraform init

# Show changes required by the current configuration
plan:
    terraform plan -out=tfplan

# Apply the changes required to reach the desired state
apply:
    terraform apply tfplan

# Destroy all remote objects managed by this Terraform configuration
destroy:
    terraform plan -destroy -out=tfplan
    @echo "Review the destruction plan carefully!"
    @echo "To proceed with destruction, run: just apply"

# Clean up local Terraform files
clean:
    rm -rf .terraform/
    rm -f tfplan
    rm -f .terraform.lock.hcl

# Format Terraform files
fmt:
    terraform fmt -recursive

# Validate Terraform files
validate:
    terraform validate

# Show current workspace
workspace-show:
    terraform workspace show

# Create and switch to a new workspace
workspace-new name:
    terraform workspace new {{name}}

# Switch to an existing workspace
workspace-select name:
    terraform workspace select {{name}}

# List all workspaces
workspace-list:
    terraform workspace list

# Combined recipe to plan and apply in one command (use with caution)
deploy: plan apply

# Combined recipe to initialize, plan and apply in one command (use with caution)
setup: init plan apply

# Combined recipe to destroy and clean up
teardown: destroy apply clean

# Execute Python Boto3 script to get a formated list of S3 buckets
get-buckets: 
  pipenv run python getbuckets.py

La ventaja de usar un task runner es que podemos encadenar procesos, agregar lógica e integrar herramientas. Como se observa en el justfile, hemos agregado bastantes capacidades tanto para correr secuencias con Terraform como para combinarlas con otras herramientas, como nuestro script en Python.

Vamos a modificar el devbox.json para usar las tareas que más nos interesen:

{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "direnv@latest",
    "awscli2@latest",
    "python@3.12",
    "terraform@latest",
    "just@latest",
    "zellij@latest",
    "pipenv@latest",
    "starship@latest"
  ],
  "env_from": ".env",
  "shell": {
    "init_hook": [
      "echo 'Welcome to devbox!'"
    ],
    "scripts": {
      "tasklist": "just",
      "tfinit": "just init",
      "tfplan": "just plan",
      "tfapply": "just apply",
      "tfdestroy": "just destroy",
      "tfdeploy": "just deploy",
      "tfsetup": "just setup",
      "tfteardown": "just teardown",
      "tfclean": "just clean",
      "getbuckets": "just get-buckets"
    }
  }
}

Para empezar, verificamos la lista de tareas disponibles en Just:

Just tasks
Just tasks

Ahora intentemos obtener la lista de buckets:

Pipenv get buckets
Pipenv get buckets

Inicialicemos el entorno de Terraform:

Terraform init
Terraform init

Ahora podemos ejecutar un Terraform plan:

Terraform plan
Terraform plan

Todo bien, vamos a ejecutar un deploy(apply):

Terraform apply
Terraform apply

Los recursos se crearon correctamente en AWS:

AWS Console
AWS Console

La instancia es accesible:

AWS Console
AWS Console

Hemos comprobado que los scripts y la automatización funciona, podría tranquilamente ser ejecutado desde un CI también.

Ahora, vamos a destruir los recursos y limpiar el ambiente:

devbox tfteardown

Devbox tfdestroy
Devbox tfdestroy
Devbox tfapply
Devbox tfapply
Terraform tfclean
Terraform tfclean

Verificando en la consola:

Terraform apply
Terraform apply

Conclusiones

Las dependencias pueden convertirse en un infierno, especialmente para quienes trabajan con diferentes tecnologías a la vez. Devbox nos permite tener un host limpio, solo teniendo instaladas las aplicaciones que el usuario realmente necesita de manera permanente.

Nos permite, además, compartir un ambiente entre todo un equipo, fomentar buenas prácticas, y es posible extenderlo y automatizarlo programáticamente.

A mi entender, aprender a usar estas herramientas debería ser un capítulo básico en el aprendizaje de todo informático.


comments powered by Disqus

Publicaciones recomendadas al azar