El manejador de dependencias que me gusta más para ambientes de desarrollo.
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:
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:
El lector conocedor habrá de percatarse que estoy usando un entorno Nix y un multiplexador de terminal (Zellij).
Nix es un ecosistema de varios proyectos de código abierto soportados por su comunidad.
Los más relevantes en mi opinión son:
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 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.
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.
Creamos un directorio simple con el comando mkdir
e ingresamos:
mkdir devbox-basics
cd devbox-basics
Devbox puede generar su archivo de configuración automáticamente:
devbox init
cat devbox.json
Podemos identificar tres secciones principales:
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.
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 .
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"
]
}
}
}
Para iniciar la shell de Devbox simplemente ejecutamos el siguiente comando:
devbox shell
La pantalla resultante debería ser similar a esta:
Podemos notar que el “Prompt” ha cambiado indicando que estamos dentro de la shell de Devbox.
Verificamos las dependencias y probemos el virtualenv:
A partir de aquí ya podemos desarrollar ya sea en Terraform y/o Python sin problemas.
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
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:
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.
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:
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"
]
}
}
}
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"
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:
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.
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 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 tenemosdirenv
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.
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:
Ahora intentemos obtener la lista de buckets:
Inicialicemos el entorno de Terraform:
Ahora podemos ejecutar un Terraform plan:
Todo bien, vamos a ejecutar un deploy(apply):
Los recursos se crearon correctamente en AWS:
La instancia es accesible:
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
Verificando en la consola:
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.
Keyboard Shortcuts
Command | Function |
---|---|
? (Shift+/) | Bring up this help modal |
g+h | Go to Home |
g+p | Go to Posts |
g+e | Open Editor page on GitHub in a new tab |
g+s | Open Source page on GitHub in a new tab |
r | Reload page |