Subir automáticamente tu app a la play store

Últimamente estoy muy metido en esto del CI/CD (Integración continúa/Despliegue continuo) que no significa, ni más ni menos, que las actualizaciones a los repositorios de código generan automáticamente todos los artefactos necesarios para poder comprobar que están correctos y que pueden desplegarse en producción, e incluso desplegarlos si así lo consideramos.

En el caso de servicios en internet de cualquier tipo (webs, APIs, etc.) el método de despliegue suele ser más sencillo de automatizar (básicamente generas un contenedor con una versión nueva y lo «empujas» al sistema de producción), más sencillo incluso si utilizamos kubernetes o ansible. Pero en el caso de distribución de aplicaciones a instalar en clientes es más complicado. En el caso que traemos aquí lo que queremos hacer es subir a la tienda de google play (y ya veremos qué hacemos con apple) una nueva versión de una aplicación móvil.

En principio debería ser sencillo, ya que solo tenemos que utilizar el API de Google Play para desarrolladores. Pero antes, tenemos que conseguir ciertas «autorizaciones» de google que no son tan triviales como deberían. Para haceros la tarea un poco más sencilla, aquí os dejo un script python que hace justo lo que queremos: subir a la tienda nuestro apk recién compilado al canal alpha (upload_apks.py)

"""Uploads an apk to the alpha track."""

import argparse

from googleapiclient import discovery
import httplib2
from oauth2client.service_account import ServiceAccountCredentials
from oauth2client import client

TRACK = 'alpha'  # Can be 'alpha', beta', 'production' or 'rollout'
SERVICE_ACCOUNT_EMAIL = (
    'ENTER_YOUR_SERVICE_ACCOUNT_EMAIL_HERE@developer.gserviceaccount.com')

# Declare command-line flags.
argparser = argparse.ArgumentParser(add_help=False)
argparser.add_argument('package_name',
                       help='The package name. Example: com.android.sample')
argparser.add_argument('apk_file',
                       nargs='?',
                       default='test.apk',
                       help='The path to the APK file to upload.')
argparser.add_argument('key_file',
                       nargs='?',
                       default='key.json',
                       help='key in json format for service account')
argparser.add_argument('version',
                       nargs='?',
                       default='New version',
                       help='Version name')

def main():
  # Process flags and read their values.
  flags = argparser.parse_args()

  package_name = flags.package_name
  apk_file = flags.apk_file
  key_file = flags.key_file
  version = flags.version
  
  credentials = ServiceAccountCredentials.from_json_keyfile_name(
      key_file,
      scopes='https://www.googleapis.com/auth/androidpublisher')
  http = httplib2.Http()
  http = credentials.authorize(http)

  service = discovery.build('androidpublisher', 'v3', http=http)

  try:
    edit_request = service.edits().insert(body={}, packageName=package_name)
    result = edit_request.execute()
    edit_id = result['id']

    apk_response = service.edits().apks().upload(
        editId=edit_id,
        packageName=package_name,
        media_body=apk_file).execute()

    print ('Version code {} has been uploaded'.format(apk_response['versionCode']))

    track_response = service.edits().tracks().update(
        editId=edit_id,
        track=TRACK,
        packageName=package_name,
        body={u'releases': [{
            u'name': version,
            u'versionCodes': [apk_response['versionCode']],
            u'status': u'completed',
        }]}).execute()

    print ('Track {} is set for release(s) {}' .format (
        track_response['track'], str(track_response['releases'])))

    commit_request = service.edits().commit(
        editId=edit_id, packageName=package_name).execute()

    print ('Edit "{}" has been committed'.format(commit_request['id']))

  except client.AccessTokenRefreshError:
    print ('The credentials have been revoked or expired, please re-run the '
           'application to re-authorize')

if __name__ == '__main__':
  main()

Antes de poder ejecutar este código deberás instalar algunas librerías (o ponerlas en el Dockerfile)

apt-get update -y
apt-get install python3 -y
apt-get install python3-pip -y
pip3 install google-api-python-client
pip3 install --upgrade oauth2client

Y después crear un archivo pc-api.json con este contenido (que luego os explico como conseguir):

{
  "type": "service_account",
  "project_id": "<project-id>",
  "private_key_id": "<private-key-id>",
  "private_key": "-----BEGIN PRIVATE KEY-----...\n-----END PRIVATE KEY-----\n",
  "client_email": "<service-account>",
  "client_id": "<client-id>",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/<service-account>"
}

Y ya, por último, solo nos quedaría ejecutar el comando con estos parámetros:

upload_apks.py com.paquete.app apk/app-release.apk res/pc-api.json ${VERSION}

Y ahora la parte más pesada… Los datos a rellenar en pc-api.json, para ello deberás configurar un proyecto API en la consola cloud de Google y crear una cuenta de servicio… Este no es un proceso inmediato, pero hay buenas instrucciones en la página de google.

Luego hay que ir a la sección de la Google Play Console al apartado Acceso a API, donde deberás vincular el proyecto que acabas de crear y crear las cuentas de servicio necesarias.

Para después en la sección de Usuarios y permisos dar permisos a la cuenta de servicio que acabamos de crear, al menos estos:

El tema de extraer la clave privada y eso ya os lo dejo para después por si alguien tiene curiosidad…

ACTUALIZACIÓN 4/01/2022

Como ahora Google solo deja subir bundles (.aab) dejo aquí el código modificado para poder hacerlo (según el tipo de archivo que elijamos):

#!/usr/bin/env python3
#
# Copyright 2014 Marta Rodriguez.
# Modified by Jose Antonio Espinosa (2021-2022)
#
# Licensed under the Apache License, Version 2.0 (the 'License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Uploads an apk/aab to the alpha track."""

import argparse

from googleapiclient import discovery
import httplib2
from oauth2client.service_account import ServiceAccountCredentials
from oauth2client import client
import os.path
import mimetypes

mimetypes.add_type("application/octet-stream", ".apk")
mimetypes.add_type("application/octet-stream", ".aab")

TRACK = 'alpha'  # Can be 'alpha', beta', 'production' or 'rollout'
SERVICE_ACCOUNT_EMAIL = (
    'ENTER_YOUR_SERVICE_ACCOUNT_EMAIL_HERE@developer.gserviceaccount.com')

# Declare command-line flags.
argparser = argparse.ArgumentParser(add_help=False)
argparser.add_argument('package_name',
                       help='The package name. Example: com.android.sample')
argparser.add_argument('apk_file',
                       nargs='?',
                       default='test.apk',
                       help='The path to the APK file to upload.')
argparser.add_argument('key_file',
                       nargs='?',
                       default='key.json',
                       help='key in json format for service account')
argparser.add_argument('version',
                       nargs='?',
                       default='New version',
                       help='Version name')

def main():
  # Process flags and read their values.
  flags = argparser.parse_args()

  package_name = flags.package_name
  apk_file = flags.apk_file
  key_file = flags.key_file
  version = flags.version
  extension = os.path.splitext(apk_file)[1]
  
  # Create an httplib2.Http object to handle our HTTP requests and authorize it
  # with the Credentials. Note that the first parameter, service_account_name,
  # is the Email address created for the Service account. It must be the email
  # address associated with the key that was created.
  credentials = ServiceAccountCredentials.from_json_keyfile_name(
      key_file,
      scopes='https://www.googleapis.com/auth/androidpublisher')
  http = httplib2.Http()
  http = credentials.authorize(http)

  service = discovery.build('androidpublisher', 'v3', http=http)

  try:
    edit_request = service.edits().insert(body={}, packageName=package_name)
    result = edit_request.execute()
    edit_id = result['id']

    if extension == '.apk':
        apk_response = service.edits().apks().upload(
            editId=edit_id,
            packageName=package_name,
            media_body=apk_file).execute()
    else:
        apk_response = service.edits().bundles().upload(
            editId=edit_id,
            packageName=package_name,
            media_body=apk_file).execute()

    print ('Version code {} has been uploaded'.format(apk_response['versionCode']))

    track_response = service.edits().tracks().update(
        editId=edit_id,
        track=TRACK,
        packageName=package_name,
        body={u'releases': [{
            u'name': version,
            u'versionCodes': [apk_response['versionCode']],
            u'status': u'completed',
        }]}).execute()

    print ('Track {} is set for release(s) {}' .format (
        track_response['track'], str(track_response['releases'])))

    commit_request = service.edits().commit(
        editId=edit_id, packageName=package_name).execute()

    print ('Edit "{}" has been committed'.format(commit_request['id']))

  except client.AccessTokenRefreshError:
    print ('The credentials have been revoked or expired, please re-run the '
           'application to re-authorize')

if __name__ == '__main__':
  main()

Big Sur + iphone en mi ubuntu

Continuando con lo que escribí en la anterior entrada, el siguiente objetivo es múltiple: conseguir la última versión de mac os (Big Sur) y poder utilizar un teléfono físico con la máquina virtual para poder generar la versión final de las aplicaciones para ios.

Utilizar sosumi en ubuntu es muy sencillo y relativamente poco complicado, pero al intentar actualizar a BigSur siempre daba el mismo problema… Se descargaba la actualización, comanzaba a ejecutarla y tras el primer reinicio… nada… Seguimos en Catalina. Tras varios intentos infructuosos decidí seguir otro camino. Decidí seguir esta entrada y crear un usb bootable para instalar Big Sur desde cero. Eso lo conseguí añadiendo el dispositivo usb al archivo launch con esta línea:

-device usb-host,vendorid=0x0951,productid=0x16ae

El vendorid y productid lo podemos sacar con un comando lsusb:

Bus 003 Device 072: ID 0951:16ae Kingston Technology DT microDuo 3C

Con esta línea en el archivo launch conseguimos que la máquina macos reconozca el usb cuando lo pinchemos y nos permita formatearlo e instalarle el instalador de BigSur. Arrancando la máquina con este usb pinchado nos permitiría ejecutar una instalación limpia de Big Sur (en teoría)… Pero la teoría y la práctica en la práctica no coinciden y no conseguí que funcionase la instalación desde el usb con sosumi, así que, guardando mi dispositivo USB por si las moscas (spoiler alert, si que lo vamos a necesitar) busqué otras formas de ejecutar macos en mi ubuntu. El siguiente en la lista y que me ha dado buenos resultados está en esta url:

https://github.com/kholia/OSX-KVM

Siguiendo las instrucciones de instalación se consigue más o menos de manera sencilla lo mismo que con sosumi, resumiendo, esto es lo que hay que hacer:

echo 1 | sudo tee /sys/module/kvm/parameters/ignore_msrs
sudo apt-get install qemu uml-utilities virt-manager git \
    wget libguestfs-tools p7zip-full -y
git clone --depth 1 https://github.com/kholia/OSX-KVM.git
cd OSX-KVM

Llegados a este punto deberíamos poder bajarnos de la web de apple el instalador del sistema que queremos, siguiendo las instrucciones a mi no me ha funcionado y terminaba instalándose catalina cada vez… Así que recuperando mi USB de instalación de BigSur decidí arriesgarme y utilizar la imagen que creé para arrancar, para ello copié el archivo BaseSystem.dmg que se encuentra en el directorio BaseSystem del USB creado al directorio y lo convertí con el comando:

qemu-img convert BaseSystem.dmg -O raw BaseSystem.img

Lo siguiente es crear un disco duro con el tamaño que nos interese (yo lo creé de 128G para no quedarme corto) con:

qemu-img create -f qcow2 mac_hdd_ng.img 128G

Y ya podemos arrancar el sistema y hacer lo mismo que hicimos con sosumi, entrar en el gestor de discos, formatear el disco inicial y luego seleccionar la opción de reinstalación del sistema operativo… Va a tardar un rato largo pero, al final, tendrás un Big sur operativo ejecutando el comando:

./OpenCore-Boot.sh

Y como veo que este post me está quedando un poco largo, dejaremos el tema de pinchar un iphone a nuestra máquina virtual para un post posterior… Porque la cosa tiene miga (no, no es tan sencillo como lo de la memoria USB de antes).

Por ahora podéis experimentar con el sistema y veréis que tenéis algo más de control que con sosumi a cambio de tener un poco más de cuidado con lo que hacéis… Ah! y como bonus, que sepas que puedes hacer un backup de la máquina virtual simplemente copiando el directorio OSX-KVM y podrás restaurar la máquina a ese mismo estado (incluso llevártela a otro ordenador) sin ningún problema.

Mac os en mi Ubuntu

Llevo varios años desarrollando aplicaciones móviles, la última nomorepass, y me encuentro siempre en la tesitura de tener que compilar la aplicación en un mac nativo. Supongo que eso es un peaje que Apple pide por «dejarte» usar ios, pero es que gastarse unos cuantos miles de euros solo para compilar una aplicación es bastante aberrante.

CREATOR: gd-jpeg v1.0 (using IJG JPEG v80), quality = 82

Hace unos años me compré un macbook pro de 15″ que me ha dado buen servicio hasta hace cosa de tres años en que falló el chip gráfico de nvidia y el servicio técnico me cambió la placa entera (700 Euros) y me querían cobrar otros 400 si se me ocurría reclamar mi placa vieja… Negociazo redondo para apple cuando cambie un chip y le endose la misma placa a otro ingenuo… En fin, que se me han quitado las ganas de comprar un apple y el que tenemos en la oficina es un poco «lento» y tarda una eternidad en compilar un simple proyecto (más con cada actualización del sistema operativo). Por esto he estado intentando de todas las maneras posibles poder hacer esa compilación en una máquina profesional de verdad que corriese linux.. He intentado hackintosh, he intentado virtualbox, etc… Hasta que hace poco encontré que se puede instalar una versión de mac en qemu… Una versión genuina, sin modificar… Y dicho y hecho…

Forma sencilla

La forma más sencilla de instalar mac os (Catalina) en un Ubuntu es instalar el paquete snap sosumi. Os recomiendo mucho que echéis un vistazo a este video, ya que explica todo con cierta profundidad. Básicamente esto es lo que necesité hacer:

sudo snap install sosumi

Una vez instalado (es rápido), se puede ejecutar incluso desde el lanzador de aplicaciones buscando sosumi. La primera vez que se lanza te mostrará la pantalla de recuperación y deberás abrir el programa Disk Utility para dar formato al disco virtual (inicialmente le da una capacidad limitada, pero puedes ampliarlo antes de hacer este paso):

En Disk Utility selecciona el primer disco y dale formato con la opción Erase… Ponle el nombre que quieras (por ejemplo MacHD) y debería quedar algo así:

Luego, cierra la aplicación y ves a la opción de Reinstalar macOS… Y listo para instalar

Aceptas la licencia y seleccionas el disco para instalar y eso es todo… Tendrás una máquina con macOS catalina lista para ejecutar.

Hay algunas cosillas interesantes a hacer como cambiar la resolución de la pantalla, para eso os recomiendo que sigáis este procedimiento, o aumentar la memoria o los cores que se hace editando el archivo ~/snap/sosumi/common/launch y teniendo cuidado en no poner cosas disparatadas.

El mayor problema que me he encontrado con este método es que no he conseguido actualizarlo a la nueva versión Big Sur, por lo que su utilidad queda un poco limitada. Sin embargo, he encontrado un método (un poco rebuscado, eso si) para instalar BigSur en qemu y poder utilizar mi máquina linux para compilar con xcode… Pero eso si, os lo contaré cuando lo tenga un poco más pulido

Añadir https y let’s encrypt a tu aplicación con docker

Una vez que hemos empezado a «dockerizar» aplicaciones, y antes de saltar al siguiente nivel (kubernetes por ejemplo) nos encontramos con la necesidad de pasar a produccion las aplicaciones que vamos desarrollando y, quizá, utilizar un gestor como Kubernetes nos haga más complicado utilizar https. Hay dos soluciones principales que he utilizado para distintos servicios y que os voy a comentar muy brevemente aquí: usar apache como proxy inverso instalado en la máquina host o utilizar un docker con el proxy inverso en nginx.

Para los dos casos vamos a suponer que tenemos un contenedor docker con una aplicación web que tenemos expuesto en el puerto 8080 (por ejemplo).

Método 1: Apache Nativo

Empecemos por el primer sistema, el que primero se me ocurrió y que tiene sus ventajas y sus inconvenientes. Básicamente consiste en instalar de manera nativa el servidor apache, el módulo mod_proxy y hacer que actúe como un proxy inverso para los dominios que necesitemos. Os explico los pasos suponiendo que estáis instalando en una máquina ubuntu recien provisionada:

sudo apt-get install -y apache2 libapache2-mpm-itk
sudo a2enmod rewrite
sudo ufw allow in "Apache Full"
sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install -y python-certbot-apache
sudo service apache2 restart

Llegados a este paso debemos crear un archivo de configuración para la aplicación web y dejarlo en /etc/apache2/sites-available/misitio.conf algo como esto:

&lt;VirtualHost *:80>
	ServerName www.misitio.com
	AssignUserId miusuario miusuario

	ServerAdmin [email protected]
	DocumentRoot /home/miusuario/web

	&lt;Directory /home/miusuario/web>
                Options FollowSymLinks
                AllowOverride All
                Require all granted
        &lt;/Directory>
&lt;/VirtualHost>

Lo relevante es el nombre del sitio y un directorio para las páginas, que no vamos a utilizar, pero que tiene que existir para las validaciones posteriores. En este caso estoy usando también el módulo itk para que se ejecute con un usuario sin privilegos. Posteriormente a esto ejecutamos:

sudo a2ensite misitio
sudo service apache2 restart

Con esto ya tendremos el servicio apache levantado y respondiendo a peticiones, por lo que podemos solicitar el certificado (recuerda que el dns del servicio debe apuntar a la dirección IP del servidor).

sudo certbot

Esto nos preguntará qué dominios queremos proteger y si todo ha ido bien nos generará un archivo midominio-le-ssl.conf que contendrá ya los enlaces a los certificados y configuración asociada. Con lo que ya podrías acceder a https://www.midominio.com

Ahora queda la parte en la que «conectamos» este servidor con nuestro docker que, recordemos, está corriendo en el puerto 8080, para ello modificaremos el archivo de configuración que nos ha creado certbot añadiendo estas líneas:

ProxyPass / http://localhost:8080/
ProxyPassReverse / http://localhost:8080/

Reiniciamos apache y ya tenemos enganchado nuestro docker a https.

Método 2: docker que nos lo hace todo

Si el método anterior nos parece un poco pesado o no queremos tener que guardar la configuración particular de una máquina, podemos optar por añadir esto a nuestro archivo docker-compose (teniendo en cuenta que hemos llamado miservicio al servicio que tenemos en el 8080):

    https-portal:
        image: steveltn/https-portal:1
        depends_on:
            - miservicio
        ports:
            - 80:80
            - 443:443
        restart: always
        volumes:
            - ./ssl_certs:/var/lib/https-portal
        environment:
            DOMAINS: 'www.midonio.com -> http://miservicio:8080 #production' 

Y eso es todo, el servidor al levantarse se encarga de pedir los certificados y guardarlos en el directorio ssl_certs que será el único que tenemos que persistir para evitar tener que pedirlos cada vez que arranque el servidor.

Cada uno de los dos métodos tiene sus pros y sus contras (hacerlo en kubernetes es otra historia y no aplica ninguno de estos métodos), pero básicamente si queremos exponer más de un contenedor (en distintos docker-compose) la única manera es usar el proxy inverso nativo, si todo lo que queréis servir por https está en un solo docker-compose el segundo método es mucho más cómodo.

Monta tu propio cluster Kubernetes

Llevo los últimos meses intentando aprender Kubernetes después de que la experiencia con Docker fuese tan satisfactoria en todos los aspectos. No obstante con Kubernetes caía una y otra vez en los problemas de la complejidad inherente a una plataforma tan adaptada para los pasos a producción de grandes aplicaciones. Muchos de los tutoriales (incluyendo los propios de kubernetes) te instaban a instalarte minikube o usar algunos playgrounds disponibles online como Katacoda o Play with kubernetes. Al final lo que era evidente es que necesitaba un cluster k8s para poder aprender un poco más de kubernetes.

Minikube tiene importantes restricciones y los otro playground son de usar y tirar, por lo que, al final, si quería aprender de verdad tenía que construirme mi propio cluster… Y ahora mismo no me apetece pagar por tener algo puramente experimental, así que aproveché y, dado que tengo dos sobremesa en casa, decidí instalar el cluster en mis propios ordenadores y poder disfrutar de toda la potencia de kubernetes. Aquí un resumen muy resumido de lo que hay que hacer en ubuntu 18.04 (que es lo que tenía en los dos):

Primer paso: instalar docker

Eso creo que ya lo hemos tratado aquí en algunas ocasiones, no obstante ha mejorado mucho la forma de instalarlo desde entonces (en todas las máquinas):

sudo apt install docker.io
sudo systemctl enable docker
sudo addgroup docker ${USER}

Paso 2: instalar kubernetes

Igualmente en todos los nodos:

curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add
sudo apt-add-repository "deb http://apt.kubernetes.io/ kubernetes-xenial main"
sudo apt-get install kubeadm kubelet kubectl
sudo apt-mark hold kubeadm kubelet kubectl

Si todo ha ido bien podemos ver la versión que hemos instalado:

kubeadm version

Paso 3: inicializar cluster

Para inicializar el cluster primero debemos asignar un nombre a cada nodo, además, previamente tendremos que desactivar el swap que no se lleva bien con este sistema, primero con el master y luego con el resto:

sudo swapoff -a
sudo hostnamectl set-hostname master-node

Y luego en el resto:

sudo hostnamectl set-hostname worker01

Con todos los nodos ya con nombre podemos inicializar en el maestro el cluster:

sudo kubeadm init --pod-network-cidr=10.244.0.0/16

Como resultado (y si todo va bien) el comando nos devolverá el comando a ejecutar en cada uno de los nodos, algo así como:

kubeadm join --discovery-token abcdef.1234567890abcdef --discovery-token-ca-cert-hash sha256:1234..cdef 1.2.3.4:6443

Debemos guardar ese comando ya que lo tendremos que ejecutar posteriormente en cualquier nodo que queramos unir al cluster

Para poder administrar con kubectl necesitamos guardar la configuración que acabamos de generar en el usuario que estemos usando… Dentro del master es sencillo:

kubernetes-master:~$ mkdir -p $HOME/.kube
kubernetes-master:~$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
kubernetes-master:~$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

Con esto ya podremos lanzar nuestros comandos kubectl contra nuestro nuevo cluster

Paso 4: Desplegar red en el cluster

Tal como está configurado ahora mismo no hay forma de comunicarse entre los pods y el resto, vamos a instalar flannel como red virtual, para ello ejecutar:

sudo kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml

Al cabo de un rato podremos ver si los pods están correctamente desplegados con:

kubectl get pods --all-namespaces

Paso 5: añadir nodos a la red

Como ya dijimos en el paso 3, tenemos un comando a ejecutar en cada nodo de la red para unirse al cluster que acabamos de crear. Ejecutamos ese comando en cada uno de los nodos que queremos unir y luego, dentro del master, podemos comprobar si están presentes todos los nodos y el estado en que están:

kubectl get nodes
NAME STATUS ROLES AGE VERSION
master-node Ready master 3d17h v1.18.5
worker01 Ready 3d17h v1.18.5
worker02 Ready 2d22h v1.18.5
worker03 Ready 2d17h v1.18.5

Si todos están en estado Ready hemos triunfado… Listos para desplegar lo que queramos en nuestro cluster casero… A ver si nos da tiempo a explorarlo con cierta extensión.