Déploiements de VM Proxmox automatisées avec Packer, Terraform et Cloud Init

Magazine
Marque
SysOps Pratique
Numéro
146
Mois de parution
novembre 2024
Spécialité(s)


Résumé

Que ce soit au niveau professionnel ou sur mon lab personnel, j’ai besoin de (re)déployer de manière rapide et reproductible un grand nombre de machines virtuelles. L’utilisation conjointe de ces technologies permet d’instancier automatiquement des machines virtuelles depuis une image standardisée, et ce, en quelques instants.


Body

Proxmox VE est une solution de virtualisation bien connue et regardée encore plus attentivement depuis la hausse des tarifs de VMWare vSphere par Broadcom. Cette solution permet d’instancier des conteneurs LXC que nous n’utiliserons pas ainsi que des VM en utilisant l’hyperviseur KVM. Je partirai du principe pour la suite que vous disposez d’un environnement Proxmox déjà opérationnel.

Packer et Terraform sont des outils de la société Hashicorp permettant via un langage simple de créer pour le premier des templates de VM et pour le second de déployer de l’infrastructure avec du code. Les deux outils sont agnostiques vis-à-vis du cloud provider ou de l’hyperviseur, cette partie étant déléguée à des providers. Les licences de Terraform et Packer sont depuis peu non libres au sens OSI et un fork libre de Terraform existe appelé OpenTofu.

Enfin, Cloud-Init est un agent installé dans les VM qui va récupérer au premier boot de la VM des informations sur sa configuration, comme la configuration réseau, les utilisateurs à créer ou les packages à installer. Ainsi on récupère un système prêt à l’emploi dès le premier démarrage.

1. Packer

1.1 Installation et authentification

Packer dispose de deux intégrations avec Proxmox. Dans la première, il clone un template de machine virtuelle existante, dans la seconde, il déroule une installation automatisée et on doit fournir un fichier de réponse au système d’installation de la distribution. C’est cette seconde façon de procéder que je vais dérouler. Elle a l’avantage de continuer la logique « as code » du processus. Le fichier de réponse peut être intégré dans un git et les choix décidés lors de l’installation sont ainsi tracés.

L’inconvénient toutefois de cette méthode vient de l’hétérogénéité des distributions. En effet, on va retrouver du Kickstart pour les Red Hat-like, de l’Autoyast pour les SLES, du Preseed pour Debian ou encore du Cloud-Init pour Ubuntu (l’utilisation de preseed reste possible, bien que dépréciée).

Pour l’installation, des paquets existent et de manière universelle, un binaire compilé existe.

wget https://releases.hashicorp.com/packer/1.11.1/packer_1.11.1_linux_amd64.zip
unzip packer_1.11.1_linux_amd64.zip
sudo cp packer /usr/local/bin/

Nous aurons également besoin d’un compte de service et pour nous authentifier sur Proxmox je passerai par un Personnal Access Token. Bien qu’il soit possible d’être plus granulaire dans les permissions, pour rester synthétique, voici comment cela se crée sur le realm Proxmox :

pveum user add packer@pve --password packer
pveum aclmod / -user packer@pve -role PVEAdmin
pveum user token add packer@pve packer -expire 0 -privsep 0

Cette dernière nous donne le nom du PAT : packer@pve!packer et la value est le secret du PAT. Si vous souhaitez être granulaire, les privilèges à associer au rôle du Token sont Datastore.Allocate Datastore.AllocateSpace Datastore.Audit Pool.Allocate VM.Allocate VM.Audit VM.Clone VM.Config.CDROM VM.Config.Cloudinit VM.Config.CPU VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network VM.Config.Options VM.Console VM.Migrate VM.Monitor VM.PowerMgmt SDN.Use Sys.Audit Sys.Console Sys.Modify. La liste est longue et donc par souci de concision, il me semblait plus pertinent de ne pas être exhaustif même si cela reste une bonne pratique en termes de sécurité.

1.2 Un peu de code Packer

Historiquement, le code packer s’écrivait en JSON, désormais il est possible de décrire les templates au format Hashicorp Configuration Language et qui pour moi est de loin plus agréable à utiliser. Comme Terraform, Packer fournit un binaire qui traite le workflow et c’est le builder qui a la connaissance des API du système avec lequel on dialogue.

Commençons par écrire notre premier fichier packer, versions.pkr.hcl. Dans celui-ci, nous allons indiquer la version de packer et les versions des builders nécessaires. C’est dans tous les cas une bonne pratique de versionner les dépendances utilisées.

packer {
  required_version = ">= 1.11.0"
  required_plugins {
    proxmox = {
      version = ">= v1.1.8"
      source = "github.com/hashicorp/proxmox"
    }
  }
}

Commençons l’écriture de notre fichier décrivant notre template, appelons-le ubuntu-2204.pkr.hcl pour l’exemple. En premier lieu, je définis mes variables correspondant aux credentials pour se connecter à Proxmox ainsi que pour s’authentifier sur le template de VM. En effet, après l’installation, Packer redémarre la VM qui va devenir notre template, s’y connecte en SSH et l’arrête avant de la convertir en template. Ce procédé permet de valider que l’installation s’est correctement déroulée. Naturellement, les credentials SSH doivent correspondre aux credentials passés dans le fichier de réponse pour l’installation automatisée.

variable "proxmox_token_id" {
  type = string
}
 
variable "proxmox_token_secret" {
  type      = string
  sensitive = true
}
 
variable "ssh_username" {
  type = string
}
 
variable "ssh_password" {
  type      = string
  sensitive = true
}

Ensuite, nous allons définir les caractéristiques du matériel virtuel du template que nous allons déployer. Dans les caractéristiques, on va retrouver les attributs classiques de sizing cpu/ram/disque dont l’importance ici est assez limitée. Le node et le bridge sont spécifiques à mon installation de proxmox, cela sera à adapter pour vous. De même, l’ISO et le volume de déploiement sont relatifs à mon volume LVM appelé « local » sur mon instance. Idéalement, ces éléments devraient être mis en variable, mais cela alourdirait mon propos.

Dans les éléments plus significatifs, nous allons retrouver la boot command. Packer va en effet simuler une frappe au clavier sur le menu de démarrage et ajouter les paramètres désirés. Dans le cas présent, on indique à l’installer Ubuntu d’aller chercher son fichier d’auto-installation en HTTP. Sur une Rocky, on pourrait tout à fait spécifier des options pour Kickstart, dont le fichier serait également servi en HTTP. L’installation se faisant par cloud-init, l’installer Ubuntu va chercher un fichier nommé user-data exposé par Packer dans le répertoire http_directory.

source "proxmox-iso" "tpl-ubuntu-2204" {
  insecure_skip_tls_verify = true
  proxmox_url              = "https://localhost:8006/api2/json"
  username                 = var.proxmox_token_id
  token                    = var.proxmox_token_secret
 
  node = "zaphod"
 
  vm_name              = "tpl-ubuntu-2204"
  template_description = "Ubuntu 22.04 Cloud Init template"
  os                   = "l26"
  sockets              = 1
  cores                = 2
  memory               = 2048
 
  bios                    = "seabios"
  qemu_agent              = true
  cloud_init              = true
  cloud_init_storage_pool = "local"
  unmount_iso             = true
 
  vga {
    type = "virtio"
  }
 
  scsi_controller = "virtio-scsi-pci"
  disks {
    disk_size    = "30G"
    format       = "raw"
    storage_pool = "local"
    type         = "virtio"
  }
  network_adapters {
    model    = "virtio"
    bridge   = "vmbr1"
    firewall = "false"
  }
 
  http_directory = "autoinstall/ubuntu2204"
  iso_file         = "local:iso/ubuntu-22.04.4-live-server-amd64.iso"
  iso_storage_pool = "local"
  iso_checksum     = "sha256:45F873DE9F8CB637345D6E66A583762730BBEA30277EF7B32C9C3BD6700A32B2"
 
  boot_wait = "10s"
  boot_command = [
    "<spacebar><wait><spacebar><wait><spacebar><wait><spacebar><wait><spacebar><wait>",
    "e<wait>",
    "<down><down><down><end>",
    " autoinstall ds=nocloud-net\\;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/",
    "<f10>"
  ]
 
  ssh_username = var.ssh_username
  ssh_password = var.ssh_password
  ssh_timeout = "60m"
}

Enfin, nous arrivons à l’étape de build. Pour cela, on indique notre ressource tpl-ubuntu-2204 utilisant le builder proxmox-iso. Packer dispose d’un builder distinct pour créer un template proxmox depuis un clone. Lors du build, j’utilise un provisionner pour réaliser certaines tâches comme réinitialiser les ID.

build {
  sources = ["proxmox-iso.tpl-ubuntu-2204"]
  provisioner "shell" {
    script       = "provisionners/postinstall-ubuntu.sh"
    pause_before = "10s"
    timeout      = "10s"
  }
}

1.3 Des scripts d’installation

Dans le http_directory du fichier packer, j’avais indiqué le chemin autoinstall/ubuntu2204. J’utilise cette arborescence afin de pouvoir organiser mes templates par type d’OS. Sur une Ubuntu qui n’est pas installée avec Preseed, l’installer va y chercher un fichier user-data. Voyons la structure basique de ce fichier.

Dans la mesure du possible, je tente de pousser le moins de choses possible dans le template afin de le rendre le plus versatile possible. Le reste de la configuration est ensuite déployé par un outil de gestion de configuration. Cela évite également de maintenir une post-install dans le template et potentiellement en double dans Ansible. Par conséquent, je crée un user avec son hash qui doit correspondre à ce qui sera ensuite passé à Packer que je force en admin dans les late-command. Je pourrais tout aussi bien faire une authentification par clé, mais par simplicité je reste en login/password ici.

Pour rester concis, je définis un partitionnement par défaut. En pratique, un découpage CIS benchmark est bienvenu ne serait-ce que pour maîtriser le process entièrement et pouvoir appliquer le même partitionnement sur d’autres distributions.

#cloud-config
autoinstall:
  version: 1
  apt:
    disable_components: []
    fallback: abort
    geoip: false
    preserve_sources_list: false
    primary:
      - arches: [default]
        uri: http://archive.ubuntu.com/ubuntu/
  codecs:
    install: false
  drivers:
    install: false
  identity:
    hostname: linux
    password: $y$j9T$/iNLfLo5yqY4CR6tpl/3c.$3t2JcpDhEi80B8ZFAxWl/guI8pzpnD0alCYUd.PquRD
    realname: julien
    username: julien
  kernel:
    package: linux-generic
  keyboard:
    layout: fr
    toggle: null
    variant: ''
  locale: en_US.UTF-8
  network:
    ethernets:
      ens18:
        dhcp4: true
    version: 2
  source:
    id: ubuntu-server
    search_drivers: false
  ssh:
    allow-pw: true
    authorized-keys: []
    install-server: true
  packages:
    - qemu-guest-agent
    - vim
    - rsyslog
    - iproute2
    - net-tools
    - traceroute
    - curl
    - screen
    - less
    - iputils-ping
  package_update: true
  package_upgrade: true
  updates: all
  storage:
    layout:
    name: lvm
 
  late-commands:
    - "echo 'Defaults:julien !requiretty' > /target/etc/sudoers.d/julien"
    - "echo 'julien ALL=(ALL) NOPASSWD: ALL' >> /target/etc/sudoers.d/julien"
    - "chmod 440 /target/etc/sudoers.d/julien"

Enfin, j’ai indiqué appeler un script de post-install en fin de build, voici ci-dessous le contenu de mon fichier provisionners/postinstall-ubuntu.sh. Dans ce script, je nettoie les logs et le machine-id, il serait aussi possible d’y nettoyer les clés d’hôte SSH. Dans un environnement multidistributions, il serait plus élégant de faire appel à un provisionner pour ce qui s’applique à tous les systèmes puis un autre script pour ce qui est spécifique à la distribution.

#!/bin/bash
 
sudo service rsyslog stop
 
if [ -f /var/log/wtmp ]; then
    sudo truncate -s0 /var/log/wtmp
fi
if [ -f /var/log/lastlog ]; then
    sudo truncate -s0 /var/log/lastlog
fi
 
sudo rm -rf /tmp/*
sudo rm -rf /var/tmp/*
 
sudo bash -c "echo 'uninitialized' > /etc/machine-id"
if [ -f /var/lib/dbus/machine-id ]; then
    sudo rm /var/lib/dbus/machine-id
fi
 
sudo cat /dev/null > ~/.bash_history && history -c
history -w
 
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s
 
sudo sed -i 's|nocloud-net;seedfrom=http://.*/||' /etc/default/grub
sudo sed -i 's/autoinstall//g' /etc/default/grub
sudo update-grub
sudo rm -f /etc/netplan/00-installer-config.yaml
 
echo "datasource_list: [ConfigDrive, NoCloud]" | sudo tee -a /etc/cloud/cloud.cfg.d/99-pve.cfg
sudo rm -f /etc/cloud/cloud.cfg.d/subiquity-disable-cloudinit-networking.cfg
sudo cloud-init clean –logs

1.4 On peut lancer ?

Nos codes sont en principe au complet, il est maintenant possible de déployer. Je vais ici lancer l’exécution directement depuis l’hôte Proxmox, si ce n’est pas votre cas, il faudra redéfinir la variable proxmox_url dans le fichier de conf Packer.

Commençons par indiquer nos secrets, cette manière peut aussi être exploitée au travers d’une CI/CD :

export PKR_VAR_proxmox_token_id="packer@pve!packer"
export PKR_VAR_proxmox_token_secret="token_secret"
export PKR_VAR_ssh_username="julien"
export PKR_VAR_ssh_password="MonSuperMotdePasse"

Téléchargeons les plugins requis :

packer init versions.pkr.hcl

Vérifions la syntaxe et corrigeons l’indentation :

packer fmt ubuntu-2204.pkr.hcl

Et enfin, lançons le build :

packer build ubuntu-2204.pkr.hcl

Si vous accédez à la console de la VM à cette étape vous devriez pouvoir visualiser l’installation de la VM s’opérer. Lorsque le build est terminé, un log similaire à ceci devrait apparaître en fin de build :

==> proxmox-iso.tpl-ubuntu-2204: Stopping VM
==> proxmox-iso.tpl-ubuntu-2204: Converting VM to template
==> proxmox-iso.tpl-ubuntu-2204: Adding a cloud-init cdrom in storage pool local
Build 'proxmox-iso.tpl-ubuntu-2204' finished after 8 minutes 43 seconds.
 
==> Wait completed after 8 minutes 43 seconds
 
==> Builds finished. The artifacts of successful builds are:
--> proxmox-iso.tpl-ubuntu-2204: A template was created: 124

Notre template est créé de manière automatisée et répétable. Si Ubuntu n’est pas votre distribution de prédilection ou bien si vous travaillez dans des environnements hétérogènes, il « suffit » d’adapter l’ISO, la boot command et préparer un fichier de réponse en rapport avec le type de distribution. C’est sur ce point que cela peut devenir complexe à maintenir dès que le périmètre de distribution s’élargit un peu trop.

2. Déploiement avec s/Terraform/OpenTofu/g

2.1 Prérequis

L’un des intérêts des outils Hashicorp réside dans l’uniformité du fonctionnement et de la configuration de ceux-ci. Terraform/OpenTofu est donc similaire à Packer et à l’image de celui-ci, OpenTofu propose un workflow commun, mais l’outil n’a aucune connaissance de l’API manipulée.

Pour ceux qui ne connaissent pas Terraform, le workflow est le suivant :

  • init : initialise le répertoire de travail et récupère les providers requis ;
  • plan : évalue les opérations à réaliser pour arriver à l’état décrit dans le code ;
  • apply : réalise les opérations pour arriver à l’état souhaité dans le code ;
  • destroy : supprime les ressources.

Terraform stocke ce qu’il connaît de l’infrastructure et le lien entre le code tf et les ressources déployées dans le state dans un fichier appelé terraform.tfstate. Celui-ci doit en principe ne pas être dans le git, mais stocké dans un backend s3 ou http, GitLab pouvant faire office de backend http afin de permettre notamment de travailler de manière collaborative.

Terraform n’est désormais plus libre, je vais utiliser son fork OpenTofu. Il n’y a pas de package spécifique, mais un installer générique qui s’occupe de l’installation de la dernière version.

wget fhttps://get.opentofu.org/install-opentofu.sh
bash install-opentofu.sh --install-method standalone

De la même manière que pour Packer, je vais utiliser un compte de service dédié avec une authentification par PAT. Si vous souhaitez être plus granulaire, les privilèges à donner sont : VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit.

pveum user add terraform@pve --password terraform
pveum aclmod / -user terraform@pve -role Administrator
pveum user token add terraform@pve terraform -expire 0 -privsep 0

Et comme pour Packer, les informations d’authentification seront lues par OpenTofu via des variables d’environnement et ainsi peu de risques de voir fuiter ces informations après un mauvais commit dans git.

export TF_VAR_proxmox_token_id="terraform@pve!terraform"
export TF_VAR_proxmox_token_secret="token_secret"
export TF_VAR_proxmox_url="https://localhost:8006/api2/json"

2.2 Variables et provider

Comme Packer, l’outil n’a aucune connaissance des API Proxmox et délègue cela aux providers. Il n’existe aucun provider officiel pour proxmox et pour ma part j’ai retenu le provider Telmate qui m’a semblé le plus mature, mais le provider bgp/proxmox semble quant à lui évoluer assez rapidement. Cependant, j’ai aussi eu quelques mauvaises surprises, notamment le fait qu’en cas d’erreur dans mon code résultant en un boot PXE, le provider est incapable d’arrêter la VM en cas de destroy et il faut faire un qm stop de celle-ci.

La configuration commence donc par indiquer le provider à utiliser et la version de terraform minimale. Voici donc notre provider.tf qui contient donc ceci :

terraform {
  required_version = ">= 1.7.0"
  required_providers {
    proxmox = {
      source = "telmate/proxmox"
      version = "3.0.1-rc3"
    }
  }
}
provider "proxmox" {
  pm_tls_insecure     = true
  pm_api_url          = var.pm_api_url
  pm_api_token_id     = var.pm_api_token_id
  pm_api_token_secret = var.pm_api_token_secret
}

Ensuite, nous allons définir nos variables d’entrées, disons dans un fichier nommé variables.tf. Cela permettra d’indiquer à OpenTofu de lire nos variables d’environnement pour récupérer les credentials de connexion à Proxmox. Dans le cas présent, je build depuis le serveur Proxmox, mais ce n’est pas une bonne pratique et une VM dédiée ou une CI/CD serait plus appropriée.

variable "pm_api_url" {
  type    = string
  default = "https://localhost:8006/api2/json"
}
 
variable "pm_api_token_id" {
  type = string
}
 
variable "pm_api_token_secret" {
  type      = string
  sensitive = true
}

2.3 Une petite VM pour la forme

Notre première ressource terraform sera de type proxmox_vm_qemu. Le nom du fichier n’a pas d’importance tant que le fichier est d’extension .tf. Je ne m’attarderai pas sur les propriétés liées au sizing de la VM CPU/RAM, etc. Cependant, deux choses importantes sont à souligner. La première l’os_type positionné à cloud-init définit la méthode de provisionning. Proxmox fournit quelques attributs simples comme ipconfig0 pour la configuration réseau notamment. Je préfère la seconde méthode qui consiste à passer un pseudo disque qui contiendra un cloud-init complet. C’est pour cette raison que je déclare une ressource IDE avec comme attribut le chemin sur le stockage proxmox et l’ID de la ressource Terraform qui sera décrite un peu plus loin.

resource "proxmox_vm_qemu" "cloudinit-test" {
  name        = "terraform-test-vm"
  desc        = "A test for using terraform and cloudinit"
  target_node = "zaphod"
  clone       = "tpl-ubuntu-2204"
  full_clone = "true"
  bios        = "seabios"
 
  agent         = 1
  agent_timeout = 120
  skip_ipv6     = true
 
  os_type = "cloud-init"
  cores    = 2
  sockets = 1
  vcpus    = 0
  cpu      = "host"
  memory   = 2048
  scsihw   = "virtio-scsi-pci"
  bootdisk = "virtio0"
  boot     = "order=virtio0"
 
  disks {
    ide {
      ide2 {
        cdrom {
          iso = "local:${proxmox_cloud_init_disk.ci.id}"
        }
      }
    }
    virtio {
      virtio0 {
        disk {
          size    = 40
          storage = "local"
        }
      }
    }
  }
 
  network {
    model = "virtio"
    bridge = "vmbr1"
  }
}

2.4 Cloud-Init entre en scène

Jusqu’ici nous avions le code pour déployer la VM depuis un template. Cloud-init va permettre de rendre la VM immédiatement utilisable en se lançant au premier boot et en exécutant les métadonnées envoyées à celui-ci.

La section user-data permet de déployer des utilisateurs, des packages, lancer des commandes, etc. Par habitude, je me limite à gérer les mises à jour et créer les comptes de connexion minimaux pour assurer la suite de la postinstallation.

Enfin la section network-config permet de pousser la configuration réseau de la machine, DHCP ou IP statique. La syntaxe est proche de la syntaxe netplan et en pratique, cloud init utilise netplan ou NetworkManager comme renderer pour appliquer la configuration désirée.

resource "proxmox_cloud_init_disk" "ci" {
  name     = "terraform-test-vm"
  pve_node = "zaphod"
  storage = "local"
 
  meta_data = yamlencode({
    instance_id    = sha1("terraform-test-vm")
    local-hostname = "terraform-test-vm"
  })
 
  user_data = <<EOT
#cloud-config
package_update: true
package_upgrade: true
package_reboot_if_required: true
packages:
- screen
- htop
manage_etc_hosts: localhost
ssh_pwauth: True
 
EOT
 
  network_config = yamlencode({
    version = 1
    config = [{
      type = "physical"
      name = "ens18"
      subnets = [{
        type            = "static"
        address         = "192.168.69.120/24"
        gateway         = "192.168.69.1"
        dns_nameservers = ["192.168.69.1"]
      }]
    }]
  })
}

2.5 On build !

À partir de maintenant nous avons le socle pour déployer entièrement notre première VM de manière automatisée. Tout d’abord, remettons au propre l’indentation et vérifions la syntaxe :

tofu fmt

Installons les providers :

tofu init
 
Initializing the backend...
 
Initializing provider plugins...
- Finding telmate/proxmox versions matching "3.0.1-rc3"...
- Installing telmate/proxmox v3.0.1-rc3…
[…]

Créons et vérifions le plan :

tofu plan -out tfplan
[…]
Plan: 2 to add, 0 to change, 0 to destroy.

Il est très important à ce niveau de vérifier ce que OpenTofu va réaliser comme actions en particulier en cas de destroy.

Si tout est conforme, on peut alors appliquer les modifications proposées :

tofu apply tfplan
proxmox_cloud_init_disk.ci: Creating...
proxmox_cloud_init_disk.ci: Creation complete after 5s [id=local:iso/tf-ci-terraform-test-vm.iso]
proxmox_vm_qemu.cloudinit-test: Creating…
[...]
proxmox_vm_qemu.cloudinit-test: Creation complete after 58s [id=ns33672748/qemu/132]
 
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Notre VM est désormais entièrement instanciée automatiquement. On peut tout à fait poursuivre avec le déploiement d’autres VM qui participeraient au déploiement de l’ensemble d’une stack applicative. Par extension, il sera possible de déployer ensuite les middlewares avec la même logique de fonctionnement avec des solutions de type Ansible ou Puppet.

Conclusion

Une fois passé l’effort de préparation de cette usine à templates les gains sont immédiats dès que l’on souhaite déployer rapidement plusieurs VM. Comparativement, la méthode à l’ancienne passe par le clone d’un template qu’il faut reconfigurer, template qu’il faut lui-même maintenir et mettre à jour.

De la même façon, procéder par itération lorsque l’on maquette un projet devient beaucoup plus efficace. Supprimer, recréer, modifier toutes les VM d’une application nécessite un temps dérisoire.

Enfin, les besoins en documentation se trouvent plus réduits. On peut se passer d’une partie de la documentation, souvent pas tout à fait mise à jour, afin de ne documenter que l’essentiel. Le code devient pour partie la documentation de ce qui est produit, il ne reste à documenter que le « comment ».



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous