Article

Implement a CI/CD Pipeline with GitHub Actions and Vultr Kubernetes Engine

Author: Donald Le

Last Updated: Tue, Feb 7, 2023
Kubernetes

Continuous integration and Continuous delivery (CI/CD) is now becoming the norm in developing software applications. This popularity of CI/CD is because adopting CI/CD pipeline allows software projects to release more often without compromising quality. In addition, with the rising popularity of Kubernetes in orchestrating containers in a microservice architecture, the CI/CD pipeline usually involves steps related to deploying the apps to the Kubernetes cluster. Implementing the CI/CD pipeline, which consists in communicating to the Kubernetes cluster, is not a trivial task since there are multiple components in the Kubernetes cluster that you need to configure to work well with the CI tool.

This article will teach you how to implement the CI/CD workflows using GitHub actions with Vultr Kubernetes Engine.

What is GitHub action

GitHub action is a CI/CD tool created by GitHub, similar to Jenkins or GitLab CI, allowing you to deliver your app to a specific environment automatically. GitHub has many actions created by communities or organizations, so you can easily connect your existing technology stack with GitHub without the need to build your integrators.

What is Vultr Kubernetes Engine

Vultr Kubernetes Engine (VKE) is a Vultr-managed Kubernetes cluster. Managing and maintaining system resources for deploying the Kubernetes cluster takes time due to the complexity of Kubernetes. Moreover, to scale the Kubernetes cluster so your application can handle more user workload, you need to add more Kubernetes nodes. Adding nodes to the Kubernetes cluster is a costly initial investment for the software team. A product like Vultr Kubernetes Engine is the solution to these problems.

With Vultr Kubernetes Engine, you can quickly scale up and down the Kubernetes cluster the way you want. You also use the pay-as-you-go pricing method, so you only need to pay for what you use, not investing a lot of money to create multi-node Kubernetes clusters to deploy your app.

The CI/CD workflow for the real-world app

To understand how CI/CD workflow works and how to build the workflow to deploy your app to the Kubernetes cluster, let's create a completed CI/CD workflow for a movie management app.

The steps for implementing the CI/CD workflow would be like below:

  1. Create a Vultr MySQL database to store the movie management app data

  2. Create a Vultr Kubernetes engine so that you can deploy the app to it

  3. Implement the movie management app using FastAPI and Python

  4. Implement the unit tests for the app

  5. Implement the API test

  6. Create a cluster role in VKE

  7. Create a service account in VKE

  8. Create a secret for the service account to store the GitHub token

  9. Create an image pull secret for deployment

  10. Create a secret file for adding environment variables and credentials for the app

  11. Create the GitHub workflow definition file

  12. Create a deployment file

  13. Push code of project to GitHub repository

  14. Check the deployed app

The movie management app is an API service. It will allow users to sign up and sign in to the app. Then from the access token the users got after signing in, they can add, update, read, and delete their movies through the app.

Prerequisites

  1. A ready-to-use Ubuntu version 22.04

  2. Install the MySQL command line client tool on your machine

  3. Install Python version 3.9 on your machine

  4. Install the curl command line tool on your machine

  5. Create a GitHub account

  6. Create a Vultr account

  7. Install kubectl command line tool on your machine

  8. Install virtualenv tool on your machine

Create a Vultr MySQL database

The movie management app stores data using MySQL database. Self-managing a MySQL database is complicated, and it takes time to maintain it. Let's use a Vultr MySQL database, then.

  1. Create the Vultr MySQL database.

    From the Vultr products page, click the "+" button to choose the resources you want to create. Then select "Add managed databases" to add a database. Choose the "MySQL" option and the server configuration and region for the MySQL database. Below is the minimum MySQL server configuration option that would satisfy the article scenario.

    • Server Type: Cloud Compute

    • Plan:

      • CPUs: 1 vCPU

      • Storage: 55 GB

      • Memory: 2 GB DDR4

      • Number of Replica Nodes: 1

    • Server Location: Singapore

    • Label: movie-management-app

    Then click the "Deploy" button and wait a few minutes for the database to be created. Vultr automatically created the admin account and default database for you.

  2. Create a movie management database and new user in the Vultr MySQL database.

    From the "Users & Databases" tab on the Vultr MySQL page:

    • Create a new database named "movie_management"

    • Create a new user with any name. The username for the MySQL database for this article is "donald".

  3. Get the database connection info.

    From the overview page of the created Vultr MySQL database, you can see the connection details for your database. The example values for this article are as below:

    • username = donald

    • password = cuongledinh

    • host = vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com

    • port = 16751

    • database = movie_management

  4. Create tables in the movie management database

    For the movie management app to work, you must create two tables: user_info and movie. Access the movie management database from your machine using the command line below:

    $ mysql --host="vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com" --port=16751  --user="donald" --password="cuongledinh"
    

    Show the list of current databases by executing the below command:

    show databases;
    

    You should see the "movie_management" database in the result:

    +--------------------+
    
    | Database           |
    
    +--------------------+
    
    | defaultdb          |
    
    | information_schema |
    
    | movie_management   |
    
    | mysql              |
    
    | performance_schema |
    
    | sys                |
    
    +--------------------+
    
    6 rows in set (0,07 sec)
    

    Use the "movie_management" database by running the following code:

        use movie_management;
    

    Create a new user_info table by executing the following sql code:

    CREATE TABLE user_info(
    
    id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    
    username VARCHAR(50) NOT NULL,
    
    password VARCHAR(500) NOT NULL,
    
    fullname VARCHAR(50) NOT NULL
    
    );
    

    Create a new movie table with the following sql code:

    CREATE TABLE movie(
    
    id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    
    title VARCHAR(50) NOT NULL,
    
    description VARCHAR(500) NOT NULL
    
    );
    

    Now that you have finished creating the Vultr MySQL database for storing movie management app data. Let's move on to create a Vultr Kubernetes Engine.

Create a VKE cluster

  1. Add a new Vultr Kubernetes Engine cluster.

    From the Vultr Product page, click "+" button and choose "Add Kubernetes". The example values for the Vultr Kubernetes Engine in this article are:

    • Cluster name: movie-management

    • Kubernetes version: v1.25.6+2

    • Cluster location: Singapore

    • Number of nodes: 1

    • Label: movie-management

    • Node pool type: Regular Cloud Compute

    • Plan:

      • CPU: 1

      • Memory: 2048 MB

      • Storage: 55 GB

      • Bandwidth: 2000 GB

    Click "Deploy Now" and wait a few minutes for Vultr finishes creating a new Vultr Kubernetes Engine. Once the VKE is ready to use, click the "Download Configuration" button to download the Kubernetes configuration file. The example file for Kubernetes configuration is: "vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml".

  2. Connect to Vultr Kubernetes Engine.

    Open your terminal in the VKE configuration file's directory, then execute the following command to create an environment variable for KUBECONFIG.

    $ export KUBECONFIG="vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml"
    

    Using kubectl to get node information:

    $ kubectl get node
    

    You should be able to see similar output as below:

        NAME                            STATUS   ROLES    AGE   VERSION
    
    movie-management-7c617d5b34c0   Ready    <none>   6d    v1.25.6
    

    You have now finished adding a new Vultr Kubernetes Engine. Let's move on to implementing the movie management app.

Implement the movie management app

You will implement the movie management app using FastAPI and Python. Let's create a new directory to store the application code.

     $ mkdir ~/Projects/movie-management-app -p

     $ cd ~/Projects/movie-management-app
  1. Install the needed dependencies to implement the app.

    Create a requirements.txt file that stores all the dependencies you need to implement the app.

    $ nano requirements.txt
    

    Copy the following content into the requirements.txt file:

    anyio==3.6.2
    
    attrs==22.2.0
    
    bcrypt==4.0.1
    
    certifi==2022.12.7
    
    cffi==1.15.1
    
    click==8.1.3
    
    cryptography==39.0.0
    
    fastapi==0.89.1
    
    greenlet==2.0.1
    
    h11==0.14.0
    
    httpcore==0.16.3
    
    httpx==0.23.3
    
    idna==3.4
    
    iniconfig==2.0.0
    
    mysql-connector-python==8.0.32
    
    packaging==23.0
    
    pluggy==1.0.0
    
    protobuf==3.20.3
    
    pycparser==2.21
    
    pydantic==1.10.4
    
    PyJWT==2.6.0
    
    pytest==7.2.1
    
    rfc3986==1.5.0
    
    sniffio==1.3.0
    
    SQLAlchemy==2.0.0
    
    starlette==0.22.0
    
    typing_extensions==4.4.0
    
    uvicorn==0.20.0
    

    Create a new Python virtual environment to isolate your machine's movie management app dependencies with other Python projects.

    $ virtualenv venv
    

    Activate the virtual environment using:

     $ source venv/bin/activate
    

    Install all the required dependencies from requirements.txt file using the following:

     $ pip install -r requirements.txt
    
  2. Create app_utils.py file.

    The app_utils.py file is for implementing utility functions. When authorizing the user credential, you will define the functions for encoding and decoding access tokens.

    Create app_utils.py and open it using the following:

     $ nano app_utils.py
    

    Add the following content to it:

    from datetime import timedelta, datetime
    
    import jwt
    
    secret_key = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
    
    algorithm = "HS256"
    
    
    
    
    
    def create_access_token(*, data: dict, expires_delta: timedelta = None):
    
        to_encode = data.copy()
    
        if expires_delta:
    
            expire = datetime.utcnow() + expires_delta
    
        else:
    
            expire = datetime.utcnow() + timedelta(minutes=15)
    
        to_encode.update({"exp": expire})
    
        encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm)
    
        return encoded_jwt
    
    
    
    
    
    def decode_access_token(*, data: str):
    
        to_decode = data
    
        return jwt.decode(to_decode, secret_key, algorithms=[algorithm])
    
  3. Create crud.py file.

    The crud.py file defines methods that allow the app to interact with the MySQL database to create, edit, retrieve, and delete data.

    Create and open crud.py by running:

    $ nano crud.py
    

    Copy the following content to crud.py file:

    import bcrypt
    
    from sqlalchemy.orm import Session
    
    
    
    import models
    
    import schemas
    
    
    
    
    
    def get_user_by_username(db: Session, username: str):
    
        return db.query(models.UserInfo).filter(models.UserInfo.username == username).first()
    
    
    
    
    
    def create_user(db: Session, user: schemas.UserCreate):
    
        hashed_password = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt())
    
        db_user = models.UserInfo(username=user.username, password=hashed_password, fullname=user.fullname)
    
        db.add(db_user)
    
        db.commit()
    
        db.refresh(db_user)
    
        return db_user
    
    
    
    
    
    def check_username_password(db: Session, user: schemas.UserAuthenticate):
    
        db_user_info: models.UserInfo = get_user_by_username(db, username=user.username)
    
        return bcrypt.checkpw(user.password.encode('utf-8'), db_user_info.password.encode('utf-8'))
    
    
    
    
    
    def create_new_movie(db: Session, movie: schemas.MovieBase):
    
        db_movie = models.Movie(title=movie.title, content=movie.content)
    
        db.add(db_movie)
    
        db.commit()
    
        db.refresh(db_movie)
    
        return db_movie
    
    
    
    
    
    def get_all_movies(db: Session):
    
        return db.query(models.Movie).all()
    
    
    
    
    
    def get_movie_by_id(db: Session, movie_id: int):
    
        return db.query(models.Movie).filter(models.Movie.id == movie_id).first()
    
    
    
    
    
    def delete_movie_by_id(db: Session, movie: schemas.Movie):
    
        db.delete(movie)
    
        db.commit()
    
  4. Create database.py file.

    The database.py file defines the configuration values to connect to the MySQL database. Create and open database.py file using the following:

    $ nano database.py
    

    Copy the following content to database.py file:

    from sqlalchemy import create_engine
    
    from sqlalchemy.ext.declarative import declarative_base
    
    from sqlalchemy.orm import sessionmaker
    
    import os
    
    
    
    DB_HOST = os.getenv('DB_HOST').strip()
    
    DB_USERNAME = os.getenv('DB_USERNAME').strip()
    
    DB_PASSWORD = os.environ.get('DB_PASSWORD').strip()
    
    DB_PORT = os.environ.get('DB_PORT').strip()
    
    DB_NAME = os.environ.get('DB_NAME').strip()
    
    
    
    
    
    SQLALCHEMY_DATABASE_URL = f"mysql+mysqlconnector://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}: {DB_PORT}/{DB_NAME}"
    
    
    
    engine = create_engine(
    
        SQLALCHEMY_DATABASE_URL,
    
    )
    
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    
    Base = declarative_base()
    
  5. Create models.py file.

    The models.py file is for creating classes that correspond to the MySQL database tables so that you can interact with the movie management database tables.

    Create and open models.py file:

    $ nano models.py
    

    Copy the following content to models.py file:

    from sqlalchemy import Column, Integer, String
    
    from database import Base
    
    
    
    
    
    class UserInfo(Base):
    
        __tablename__ = "user_info"
    
    
    
        id = Column(Integer, primary_key=True, index=True)
    
        username = Column(String, unique=True)
    
        password = Column(String)
    
        fullname = Column(String, unique=True)
    
    
    
    
    
    class Movie(Base):
    
        __tablename__ = "movie"
    
    
    
        id = Column(Integer, primary_key=True, index=True)
    
        title = Column(String)
    
        description = Column(String)
    
  6. Create schemas.py file.

    The schemas.py file defines Python classes so that you can conveniently interact with the request and response body of the APIs the app will create.

    Create and open schemas.py file using the following:

    $ nano schemas.py
    

    Copy the following content to schemas.py file:

    from pydantic import BaseModel
    
    
    
    
    
    class UserInfoBase(BaseModel):
    
        username: str
    
    
    
    
    
    class UserCreate(UserInfoBase):
    
        fullname: str
    
        password: str
    
    
    
    
    
    class UserAuthenticate(UserInfoBase):
    
        password: str
    
    
    
    
    
    class UserInfo(UserInfoBase):
    
        id: int
    
    
    
        class Config:
    
            orm_mode = True
    
    
    
    
    
    class Token(BaseModel):
    
        access_token: str
    
        token_type: str
    
    
    
    
    
    class TokenData(BaseModel):
    
        username: str = None
    
    
    
    
    
    class MovieBase(BaseModel):
    
        title: str
    
        content: str
    
    
    
    
    
    class Movie(MovieBase):
    
        id: int
    
    
    
        class Config:
    
            orm_mode = True
    
  7. Create main.py file.

    The main.py is the entry point of the application. You will create the application APIs inside this file.

    Create and open main.py file:

    $ nano main.py
    

    Copy the following content to it:

    import uvicorn
    
    from fastapi.security import OAuth2PasswordBearer
    
    from jwt import PyJWTError
    
    from sqlalchemy.orm import Session
    
    from fastapi import Depends, FastAPI, HTTPException
    
    from starlette import status
    
    import crud
    
    import models
    
    import schemas
    
    from app_utils import decode_access_token
    
    from crud import get_user_by_username
    
    from database import engine, SessionLocal
    
    from schemas import UserInfo, TokenData, UserCreate, Token
    
    
    
    models.Base.metadata.create_all(bind=engine)
    
    
    
    ACCESS_TOKEN_EXPIRE_MINUTES = 30
    
    
    
    app = FastAPI(debug=True)   
    
    
    
    def get_db():
    
        db = None
    
        try:
    
            db = SessionLocal()
    
            yield db
    
        finally:
    
            db.close()
    
    
    
    
    
    oauth2_scheme = OAuth2PasswordBearer(tokenUrl="authenticate")
    
    
    
    
    
    async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    
        credentials_exception = HTTPException(
    
            status_code=status.HTTP_401_UNAUTHORIZED,
    
            detail="Could not validate credentials",
    
            headers={"WWW-Authenticate": "Bearer"},
    
        )
    
        try:
    
            payload = decode_access_token(data=token)
    
            username: str = payload.get("sub")
    
            if username is None:
    
                raise credentials_exception
    
            token_data = TokenData(username=username)
    
        except PyJWTError:
    
            raise credentials_exception
    
        user = get_user_by_username(db, username=token_data.username)
    
        if user is None:
    
            raise credentials_exception
    
        return user
    
    
    
    
    
    @app.post("/user", response_model=UserInfo)
    
    def create_user(user: UserCreate, db: Session = Depends(get_db)):
    
        db_user = crud.get_user_by_username(db, username=user.username)
    
        if db_user:
    
            raise HTTPException(status_code=400, detail="Username already registered")
    
        return crud.create_user(db=db, user=user)
    
    
    
    
    
    @app.post("/authenticate", response_model=Token)
    
    def authenticate_user(user: schemas.UserAuthenticate, db: Session = Depends(get_db)):
    
        db_user = crud.get_user_by_username(db, username=user.username)
    
        if db_user is None:
    
            raise HTTPException(status_code=400, detail="Username not existed")
    
        else:
    
            is_password_correct = crud.check_username_password(db, user)
    
            if is_password_correct is False:
    
                raise HTTPException(status_code=400, detail="Password is not correct")
    
            else:
    
                from datetime import timedelta
    
                access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    
                from app_utils import create_access_token
    
                access_token = create_access_token(
    
                    data={"sub": user.username}, expires_delta=access_token_expires)
    
                return {"access_token": access_token, "token_type": "Bearer"}
    
    
    
    
    
    @app.post("/movie", response_model=schemas.Movie)
    
    async def create_new_movie(movie: schemas.MovieBase, current_user: UserInfo = Depends(get_current_user)
    
                           , db: Session = Depends(get_db)):
    
        return crud.create_new_movie(db=db, movie=movie)
    
    
    
    
    
    @app.get("/movie")
    
    async def get_all_movies(current_user: UserInfo = Depends(get_current_user)
    
                         , db: Session = Depends(get_db)):
    
        return crud.get_all_movies(db=db)
    
    
    
    
    
    @app.get("/movie/{movie_id}")
    
    async def get_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user)
    
                          , db: Session = Depends(get_db)):
    
        return crud.get_movie_by_id(db=db, movie_id=movie_id)
    
    
    
    
    
    @app.delete("/movie/{movie_id}", status_code=204)
    
    async def delete_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user)
    
                             , db: Session = Depends(get_db)):
    
        movie_delete = crud.get_movie_by_id(db=db, movie_id=movie_id)
    
        if movie_delete:
    
            crud.delete_movie_by_id(db=db, movie=movie_delete)
    
    
    
    
    
    if __name__ == "__main__":
    
        log_config = uvicorn.config.LOGGING_CONFIG
    
        log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s"
    
        log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s"
    
        uvicorn.run(app, log_config=log_config)
    
  8. Create a Dockerfile file.

    A Dockerfile file defines steps for building a Docker image for the movie management app. You will work with the Docker image later when implementing the CI/CD workflow.

    Create and open Dockerfile by running:

    $ nano Dockerfile
    

    Copy the following content to Dockerfile:

    FROM python:3.9
    
    WORKDIR /app
    
    COPY requirements.txt /app/requirements.txt
    
    RUN pip install -r /app/requirements.txt
    
    COPY app_utils.py /app/app_utils.py
    
    COPY crud.py /app/crud.py
    
    COPY database.py /app/database.py
    
    COPY main.py /app/main.py
    
    COPY models.py /app/models.py
    
    COPY schemas.py /app/schemas.py
    
    COPY test_unit.py /app/test_unit.py
    
    CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8084"]
    

    Now that you finished implementing the movie management app. Let's move on to see how to run the app in the local environment and try to interact with the functionalities that the app provides.

Run the App

From the current terminal, run the following commands to define environment variables for the app to connect with the MySQL database:

$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com

$ export DB_USERNAME=donald

$ export DB_PORT=16751

$ export DB_PASSWORD=thisisapassword

$ export DB_NAME=movie_management

Run the following command to bring up the app:

    $ uvicorn main:app --port 8084

You should see the app is up and running with the output below:

INFO:     Started server process [8485]

INFO:     Waiting for application startup.

INFO:     Application startup complete.

INFO:     Uvicorn running on http://127.0.0.1:8084 (Press CTRL+C to quit)

Open a new terminal, then try to create a new user using curl:

$ curl --location --request POST 'http://localhost:8084/user' \

--header 'Content-Type: application/json' \

--data-raw '{

    "username": "new_user",

    "password": "12345",

    "fullname": "Just a new user"

}'

You should see the output showing a new user has been created.

 {"username":"new_user","id":1}

Authenticate the user to get the access token so that you can add a new movie later.

$ curl --location --request POST 'http://localhost:8084/authenticate' \

--header 'Content-Type: application/json' \

--data-raw '{

    "username": "new_user",

    "password":"12345"

}'

The output should look like as:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo","token_type":"Bearer"}

Let's add a new movie using the access token above for authentication.

$ curl --location --request POST 'http://localhost:8084/movie' \

--header 'Content-Type: application/json' \

--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo' \

--data-raw '{

    "title": "It'\''s a beautiful life",

    "description":"An emotional classic movie about how life can beautiful even in storms."

}'

The output should look similar to the below:

{"title":"It's a beautiful life","description":"An emotional classic movie about how life can beautiful even in storms.","id":1}

You have finished implementing the movie management app in the local environment. Let's move on to how to write the application's unit test.

Writing unit tests

Create and open a new file named test_unit.py:

$ nano test_unit.py

Copy the following content to the test_unit.py file:

def test_encode_decode_access_token():

    from app_utils import create_access_token

    from datetime import timedelta

    input_data_create_access_token = {"sub": "cuongld"}

    access_token_expires = timedelta(minutes=10)

    access_token = create_access_token(data=input_data_create_access_token, expires_delta=access_token_expires)

    from app_utils import decode_access_token

    decoded_access_token = decode_access_token(data=access_token)

    assert decoded_access_token['sub'] == input_data_create_access_token['sub']

Here you have a test that checks whether the app can decode the encoded token correctly.

To run the test from your terminal, execute the following command:

$ pytest test_unit.py

You should see the result as passed.

Writing an integration test

FastAPI provides easy support for implementing integration tests using the TestClient.

$ nano test_integration.py

Copy the following content to the file.

from fastapi.testclient import TestClient

from main import app



client = TestClient(app)





def test_read_main():

    response = client.post("/authenticate", json={"username": "new_user", "password": "12345"}, )

    assert response.status_code == 200

Here you check whether the response status code of the authentication API is 200 if you provide the correct user credentials.

Let's run the test using pytest. Note that the app needs to connect with the actual database since you run the integration test. You need to define the environment variables before running the test.

$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com

$ export DB_USERNAME=donald

$ export DB_PORT=16751

$ export DB_PASSWORD=thisisapassword

$ export DB_NAME=movie_management

$ pytest test_integration.py

You should see the result as "pass". Now you successfully implemented the unit test and integration test for the app. Let's move on to create a cluster role in the Vultr Kubernetes Engine to set up the integration between GitHub actions and VKE.

Create a cluster role in VKE

From the folder where you have saved the VKE configuration file, create a file named clusterrole.yaml.

$ nano clusterrole.yaml

Please copy the following content to it.

apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRole

metadata:

  name: continuous-deployment

rules:

  - apiGroups:

    - ''

    - apps

    - networking.k8s.io

  resources:

    - namespaces

    - deployments

    - replicasets

    - ingresses

    - services

    - secrets

  verbs:

    - create

    - delete

    - deletecollection

    - get

    - list

    - patch

    - update

    - watch

Run the following command to create a cluster role:

$ kubectl -f apply clusterrole.yaml

You should see a message that Kubernetes created a new cluster role.

Create a service account in VKE

From the terminal, run the following command to create a new service account named github-actions-kubernetes-vultr.

$ kubectl create serviceaccount github-actions-kubernetes-vultr

You should see that Kubernetes has created a new service account named github-actions-kubernetes-vultr.

Then you create a ClusterRoleBinding to bind the continuous-deployment role to github-actions-kubernetes-vultr:

$ kubectl create clusterrolebinding continuous-deployment \

    --clusterrole=continuous-deployment

    --serviceaccount=default:github-actions-kubernetes-vultr

Run the following command to see details of the service account information:

$ kubectl get serviceaccounts github-actions-kubernetes-vultr -o yaml

You should see a similar output below:

apiVersion: v1

kind: ServiceAccount

metadata:

  creationTimestamp: "2023-01-30T10:06:29Z"

  name: github-actions-kubernetes-vultr

  namespace: default

  resourceVersion: "487023"

  uid: 43365457-4c6f-4b32-842f-fd379ab6512e

Create a secret for the service account to store the GitHub token

After having the service account with role biding for accessing the Kubernetes cluster, you need to create a secret for the service account. You will later use this secret in the GitHub workflow definition to allow GitHub action to set the Kubernetes context to deploy to Kubernetes.

Create a new file named secret-service-account.yaml to store the definition for the secret.

$ nano secret-service-account.yaml

Copy the following content to the file:

apiVersion: v1

kind: Secret

metadata:

  name: secret-github-actions-kubernetes-vultr

  annotations:

    kubernetes.io/service-account.name: "github-actions-kubernetes-vultr"

type: kubernetes.io/service-account-token

data:

  extra: YmFyCg==

Run the following command to create a secret for the service-account:

$ kubectl apply -f secret-service-account.yaml

Get the yaml output of the secret you have just created above:

$ kubectl get secret secret-github-actions-kubernetes-vultr -o yaml

The output should look similar as below:

apiVersion: v1

data:

  ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURnVENDQW1tZ0F3SUJBZ0lJU3JJRzlyUHR3cjh3RFFZSktvWklodmNOQVFFTEJRQXdUekVMTUFrR0ExVUUKQmhNQ1ZWTXhGakFVQmdOVkJBY1REVk5oYmlCR2NtRnVZMmx6WTI4eEV6QVJCZ05WQkFvVENrdDFZbVZ5Ym1WMApaWE14RXpBUkJnTlZCQU1UQ2t0MVltVnlibVYwWlhNd0hoY05Nak13TVRJM01qTXhNVEF4V2hjTk1qZ3dNVEkzCk1qTXhNVEF4V2pCUE1Rc3dDUVlEVlFRR0V3SlZVekVXTUJRR0ExVUVCeE1OVTJGdUlFWnlZVzVqYVhOamJ6RVQKTUJFR0ExVUVDaE1LUzNWaVpYSnVaWFJsY3pFVE1CRUdBMVVFQXhNS1MzVmlaWEp1WlhSbGN6Q0NBU0l3RFFZSgpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFOWFlyYnVhMFg1VlhoL1hwZDd2azVNeXFKL0liS3ozCm1nQ1N2VzlEK3ZBUTB1ZGEvWUJWUVBRSWcyaUZxTVpXeXlxS1BFRVEzblBvb09wYUVJVHNXN044aSt4VUp3dDMKc0U5cUd6UGhqYkVSdzc2c25CWlBENHFzUVpvTG4xM2tFbkZLL2thTEJSaDQ0bTA5dytzM3BBRm12elpRMmtnZwpZVzkzUHZuaWgyZVJISFJGNDJGR2xGVGo5N1hyRlBhVUpwOUNoRUZhUjJhaEF3RThXMEhQZmNoRzJNa0lYcTFsClZQbFM2UXVyR0xWbEs1Q1cxZUg0dGxxTTdwYWlBeWxJNVpnbWxqeUpGaDMvUE9WN1NmSlFZdzMyck41RjFVZnQKZVJrVUVWSDkvVjRGVjR6c1N6ZlUvWEVxWitKc21PNGQrOS90S09kMFZrMy81RTU4ek9yVkhmc0NBd0VBQWFOaApNRjh3RGdZRFZSMFBBUUgvQkFRREFnS0VNQjBHQTFVZEpRUVdNQlFHQ0NzR0FRVUZCd01DQmdnckJnRUZCUWNECkFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVFlFbC9JMW5xckFTYk9SK0FXTEN2dTAvZ0wKVURBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUtYSHFzMWRGbDNuTjEzTmlXa3pMT1ZIdnNCa2Q2aTZXN2pXYgowL01MRU9DR2pWRFFaMGVDazJhNDVEMGNlNmJHQTVvS2tCUTJHbEQ4bTJPMlNCTlVxV0JGeS8vYUtHenVKN0piCjcycVByN0RrYkxRemVFcnhtTWkwQWV5YmxQR2hkbkFWVy9SNTBWWHBWYmV5ZjhPT29CWHJIajRiUWNPRGV2NEYKZTJwaWh5bEEvK2dNSEZYMmZXcmdTSjZ6b0VmcHI4bHpDNW9OWW9iUWhyNXpjMVVSdWtwdXF4TG5vZ0JyOWVUZApnNjQ3UDlXV2pBYWpaaTFVZXRQYnlOZWVDNzNhdXViMEhsZnRNc3JKNW16TzFsVHRsVzcwWUZGUTBCUktUM3Y3Cmd6UWFCSUZNNTQ0SzQxcG5KWkhZOGxaNkFYTkg5MjBMRGhsZjR6RXNZaVFmYmFTN093PT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=

  extra: YmFyCg==

  namespace: ZGVmYXVsdA==

  token: ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJNklrYzFSVEJhWDFKcFpVbERiMnRLVm5SaVMydHFNMncyY1RjMWNUVklNakJPWDFoeGEwZFViVk53VmpnaWZRLmV5SnBjM01pT2lKcmRXSmxjbTVsZEdWekwzTmxjblpwWTJWaFkyTnZkVzUwSWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXVZVzFsYzNCaFkyVWlPaUprWldaaGRXeDBJaXdpYTNWaVpYSnVaWFJsY3k1cGJ5OXpaWEoyYVdObFlXTmpiM1Z1ZEM5elpXTnlaWFF1Ym1GdFpTSTZJbk5sWTNKbGRDMW5hWFJvZFdJdFlXTjBhVzl1Y3kxcmRXSmxjbTVsZEdWekxYWjFiSFJ5SWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXpaWEoyYVdObExXRmpZMjkxYm5RdWJtRnRaU0k2SW1kcGRHaDFZaTFoWTNScGIyNXpMV3QxWW1WeWJtVjBaWE10ZG5Wc2RISWlMQ0pyZFdKbGNtNWxkR1Z6TG1sdkwzTmxjblpwWTJWaFkyTnZkVzUwTDNObGNuWnBZMlV0WVdOamIzVnVkQzUxYVdRaU9pSTBNek0yTlRRMU55MDBZelptTFRSaU16SXRPRFF5WmkxbVpETTNPV0ZpTmpVeE1tVWlMQ0p6ZFdJaU9pSnplWE4wWlcwNmMyVnlkbWxqWldGalkyOTFiblE2WkdWbVlYVnNkRHBuYVhSb2RXSXRZV04wYVc5dWN5MXJkV0psY201bGRHVnpMWFoxYkhSeUluMC54ZnI4bGQ0dUs5WHc4TF9XUFYxcE1aZ2tzeExLbEpEeDZ5dVNWMWd5NWszWFYwOTdITXhkQmpvdEYybjdVZzZGWXUzakpvVU02U3o5WHl1ZVotQTJYb3BsaE1HSDYtZUU5UjhnWGhJUGNIejFjR1BfNU5NZ216WUlfV2k5UkNWMGdzeV9OWlRCUzUwTjVGbDl4NldiUjRzdVNVZjJqZFA0WW1wT1Z6SWIzS0hDdHdMSERWd3RXdHZ6cmtjNjVFTEVkRnVtQlduY2hmMFNIRWkyOWVBVjRUT2p0clFJeTBNSjNvTWpZY3FKZEZBcG53Zk1mendzbm5ueFBLdW5XVWxyaUJwUWV1RVFjNzZRQkZpQnk5dlNDa1k0WlN5V0tsYjFucXlFbEdhcC1pMUk1a3dkUFJTOEsxamF2U0RidGdkMGVWT2lYV1E3OG9GN3BfUFV3cXZWU0E=

kind: Secret

metadata:

  annotations:

    kubectl.kubernetes.io/last-applied-configuration: |

      {"apiVersion":"v1","data":{"extra":"YmFyCg=="},"kind":"Secret","metadata":{"annotations":{"kubernetes.io/service-account.name":"github-actions-kubernetes-vultr"},"name":"secret-github-actions-kubernetes-vultr","namespace":"default"},"type":"kubernetes.io/service-account-token"}

    kubernetes.io/service-account.name: github-actions-kubernetes-vultr

    kubernetes.io/service-account.uid: 43365457-4c6f-4b32-842f-fd379ab6512e

  creationTimestamp: "2023-01-30T10:28:26Z"

  name: secret-github-actions-kubernetes-vultr

  namespace: default

  resourceVersion: "490057"

  uid: 6b5a9dd0-2fce-4213-915c-3dc754ebd74a

type: kubernetes.io/service-account-token

Create a new GitHub action secret named KUBERNETES_SECRET in the GitHub actions secret page, and copy the above content from yaml output to the secret. You will use this KUBERNETES_SECRET

later in the GitHub workflow file.

Create an image pull secret for deployment

Go to your GitHub developer settings page, then create a new GitHub token that has the permission to "read:packages", so that Kubernetes can pull the image from the GitHub container registry.

Container registry later on. After having the GitHub token created, you create a secret using the command below:

$ kubectl create secret docker-registry github-container-registry --docker-server=ghcr.io --docker-username=<github-username> --docker-password=<token>

The output should show Kubernetes has successfully created a new secret named github-container-registry. You will use this secret in the Kubernetes deployment yaml file later.

Create a secret file for adding environment variables and credentials for the app

The movie management application requires environment variables for DB_HOST, DB_NAME, DB_USERNAME, DB_PASSWORD, DB_PORT to run. You need to create a secret file for defining these environment variables so that the Kubernetes deployment process will use these secret values later on when creating the Kubernetes pod.

You need to encode each environment value using base64 encode method first. For example, below is the command to encode "example" value using base64:

$ echo 'example' | base64

You should see the similar result as:

ZXhhbXBsZQo=

Then create a new file named secret-as-environment-variable.yaml.

$ nano secret-as-environment-variables.yaml

Copy the following content and replace the values of secrets with values of your secrets in base64 encoded format.

apiVersion: v1

kind: Secret

metadata:

  name: mysecret

type: Opaque

data:

  DB_HOST: dnVsdHItcHJvZC0xNGMyYzEwNS1mNTI4LTQxOTktYTAwZC01MjY3NmZhMTY2NzMtdnVsdHItcHJvZC0yZDMyLnZ1bHRyZGIuY29tCg==

  DB_USERNAME: ZG9uYWxkCg==

  DB_PASSWORD: Y3VvbmdsZWRpbmgK

  DB_PORT: MTY3NTEK

  DB_NAME: bW92aWVfbWFuYWdlbWVudAo=

Then run the following command to create the secret:

$ kubectl apply -f secret-as-environment-variable.yaml

You should see the message showing Kubernetes has created the secret named mysecret.

Create a GitHub action workflow

You have successfully prepared the secrets, cluster role, and service account for Kubernetes to interact with GitHub actions. Let's create a GitHub action workflow.

Go to your local project, and create a folder named .github. Inside .github folder, create a folder named workflows.

$ cd ~/Projects/movie-management-app

$ mkdir -p .github/workflows

Inside the workflows folder, you create a workflow definition file named movie-management-vultr.yaml.

$ nano movie-management-vultr.yaml

Copy the following content to it.

name: movie-management-vultr



on: push



jobs:

  test:

    name: Test

    runs-on: ubuntu-latest

    steps:

      - name: Checkout source code

        uses: actions/checkout@v3

      - name: Set up Python 3.9

        uses: actions/setup-python@v2

        with:

          python-version: 3.9 # Modify python version HERE

      #Task for installing dependencies, multi-line command

      - name: Install dependencies

        run: |

          python -m pip install --upgrade pip

          python -m pip install black pytest

          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi

      - name: run unit test and integration test

        env:

          DB_HOST: ${{ secrets.DB_HOST }}

          DB_USERNAME: ${{ secrets.DB_USERNAME }}

          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

          DB_PORT: ${{ secrets.DB_PORT }}

          DB_NAME: ${{ secrets.DB_NAME }}

        run: |

          pytest

  build:

    name: Build

    needs: test

    runs-on: ubuntu-latest

    steps:

      - name: Set up Docker Buildx

        uses: docker/setup-buildx-action@v1



      - name: Login to GitHub Container Registry

        uses: docker/login-action@v2

        with:

          registry: ghcr.io

          username: ${{github.actor}}

          password: ${{ secrets.GH_TOKEN }}



      - name: Build and push the Docker image

        uses: docker/build-push-action@v3

        with:

          push: true

          tags: |

            ghcr.io/cuongld2/vultr-cicd-githubactions:latest

            ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}

          cache-from: type=gha

          cache-to: type=gha,mode=max

  deploy:

    name: Deploy

    needs: [ test, build ]

    runs-on: ubuntu-latest

    steps:

      - name: Set the Kubernetes context

        uses: azure/k8s-set-context@v2

        with:

          method: service-account

          k8s-url: https://d77dc163-b45d-436a-ab4b-e75b921581fa.vultr-k8s.com:6443

          k8s-secret: ${{ secrets.KUBERNETES_SECRET }}

      - name: Checkout source code

        uses: actions/checkout@v3

      - name: Deploy to the Kubernetes cluster

        uses: azure/k8s-deploy@v1

        with:

          namespace: default

          manifests: |

            kubernetes/deployment.yaml

            kubernetes/service.yaml

          images: |

            ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}

The GitHub action will trigger this workflow if you push new code to the repository. Inside this workflow, you have the secrets as DB_HOST, DB_NAME, DB_USERNAME, DB_PASSWORD, DB_PORT. You use these secrets when running the test before deploying it to Kubernetes. Create five more new secrets with the actual value of the movie-management database.

You also have another secret for GH_TOKEN. You need the GitHub token to push the new container image to the GitHub container registry. You must create another GitHub token with permission for repo, write:packages. Then add a new action secret named GH_TOKEN, and put the value of the GitHub token you created in it.

You already created the secret for KUBERNETES_SECRET in the step "Create a secret for the service account to store GitHub token, " so please ignore it.

Create a deployment file

Let's create a deployment file to deploy the app to Kubernetes. Go to your local project, then create a folder named kubernetes.

$ cd ~/Projects/movie-management-app

$ mkdir kubernetes

Inside the kubernetes folder, create a file named deployment.yaml.

$ nano deployment.yaml

Copy the following content to it. Remember to replace the container image ghcr.io/cuongld2/vultr-cicd-githubactions:latest with your actual value.

---

apiVersion: apps/v1

kind: Deployment

metadata:

  name: movie-management-deployment

  labels:

    app: movie-management

spec:

  replicas: 1

  selector:

    matchLabels:

      app: movie-management

  template:

    metadata:

      labels:

        app: movie-management

    spec:

      containers:

        - name: movie-management

          image: ghcr.io/cuongld2/vultr-cicd-githubactions:latest

          ports:

            - containerPort: 8084

          env:

            - name: DB_HOST

              valueFrom:

                secretKeyRef:

                  name: mysecret

                  key: DB_HOST

                  optional: false

            - name: DB_USERNAME

              valueFrom:

                secretKeyRef:

                  name: mysecret

                  key: DB_USERNAME

                  optional: false

            - name: DB_PASSWORD

              valueFrom:

                secretKeyRef:

                  name: mysecret

                  key: DB_PASSWORD

                  optional: false

            - name: DB_PORT

              valueFrom:

                secretKeyRef:

                  name: mysecret

                  key: DB_PORT

                  optional: false

            - name: DB_NAME

              valueFrom:

                secretKeyRef:

                  name: mysecret

                  key: DB_NAME

                  optional: false

      imagePullSecrets:

        - name: github-container-registry

Push the project code to GitHub

Create a new GitHub repository, and then you push your local project to that GitHub repository in the main branch. The workflow should automatically run with successful results for all stages: test, build, and deploy.

Check the deployed app

After the GitHub workflows is finished, open the terminal in your local machine to get the Kubernetes pod.

$ kubectl get pod

You should see similar output as:

NAME                                           READY   STATUS    RESTARTS   AGE

movie-management-deployment-5bb8f8cf74-mj9x6   1/1     Running   0          27h

The application is now up and running in Kubernetes. Let's try to interact with the app from the local environment. To do that, you need to forward the app's port inside the Kubernetes cluster to the local port using the below command. Note that you need to replace the pod name with your actual one.

$ kubectl port-forward movie-management-deployment-5bb8f8cf74-mj9x6 8084:8084

You should see the similar output as below:

Forwarding from 127.0.0.1:8084 -> 8084

Forwarding from [::1]:8084 -> 8084

Handling connection for 8084

Handling connection for 8084

Handling connection for 8084

Handling connection for 8084

Handling connection for 8084

Handling connection for 8084

Let's use the authenticate API of your app to authenticate the user to see whether the deployed app is working.

$ curl --location --request POST 'http://localhost:8084/authenticate' \

--header 'Content-Type: application/json' \

--data-raw '{

    "username": "new_user",

    "password":"12345"

}'

You should see similar output as:

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ4NTc3MX0.lH8fDhiU2E1cTkola1FAuFFAwe4tZurC5TKr7kQXHg0","token_type":"Bearer"}

The app is working as fine. You have finally completed implementing the CI/CD pipeline triggered to deploy the app to the Vultr Kubernetes cluster.

Conclusion

Through the article, you have learned about how CI/CD pipeline works and have hands-on practice deploying your application to the Vultr Kubernetes cluster with the help of GitHub actions. To learn more about deploying CI/CD pipeline to Kubernetes cluster with different examples, check out other interesting Vultr articles.

Want to contribute?

You could earn up to $600 by adding new articles.