5. Pipeline integration

Pipeline integration

The Challenge

It is time to write pipelines. This is why we are here.

Good, that we already have some functions, that we can reuse - but let’s add another one which executes the Python tests:

    @function
    async def pytest(self, context: dagger.Directory) -> str:
        """Run pytest and return its output."""
        return await (
            self.backend(context)
            .with_exec(["pip", "install", "--upgrade", "pip"])
            .with_exec(["pip", "install", "--upgrade", "pytest"])
            .with_exec(["pytest", "classquiz/tests/", "--ignore=classquiz/tests/test_server.py"])
            .stdout()
        )

The tests are run on the backend, therefore we need to build the backend first.

Task 5.1: Create CI function

Add a ci function, that first runs the python tests and then returns a directory containing the scan results of the security scan.

show solution
    @function
    async def ci(self, context: dagger.Directory) -> dagger.Directory:
        """Run all pipeline stages."""
        await self.pytest(context)
        return await self.vulnerability_scan(context)

Task 5.2: Run the ci from CLI

Let’s see if we can run it from the CLI and have a look at the results:

show solution
dagger call ci --context=. export --path=.tmp

If everything went well, the scan results should again be found in the directory .tmp/scans/.

Task 5.3: Add GitHub action

As final step, we need to call the ci function on every push to the repository.

Have a look at Dagger for GitHub first and then add the action to your fork on GitHub. Keep it simple and trigger the pipeline with every push on every branch.

show solution
name: CI Pipeline
on: [ push ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: test and scan
        uses: dagger/dagger-for-github@v8
        with:
          version: latest
          verb: call
          module: .
          args: ci --context=. export --path=.

Add or alter something, push it to the repo and see if the action runs as expected.

Complete Solution

ci/src/class_quiz/main.py:

import dagger
from dagger import dag, function, object_type

@object_type
class ClassQuiz:

    @function
    async def ci(self, context: dagger.Directory) -> dagger.Directory:
        """Run all pipeline stages."""
        await self.pytest(context)
        return await self.vulnerability_scan(context)

    @function
    async def pytest(self, context: dagger.Directory) -> str:
        """Run pytest and return its output."""
        return await (
            self.backend(context)
            .with_exec(["pip", "install", "--upgrade", "pip"])
            .with_exec(["pip", "install", "--upgrade", "pytest"])
            .with_exec(["pytest", "classquiz/tests/", "--ignore=classquiz/tests/test_server.py"])
            .stdout()
        )

    @function
    async def build(self, context: dagger.Directory) -> dagger.Container:
        """Returns a container built with the given context."""
        return await dag.container().build(context)

    @function
    async def vulnerability_scan(self, context: dagger.Directory) -> dagger.Directory:
        """Builds the front- and backend, performs a Trivy scan and returns the directory containing the reports."""
        trivy = dag.trivy()

        directory = (
            dag.directory()
            .with_file("scans/backend.sarif", trivy.container(await self.build(context)).report("sarif"))
            .with_file("scans/frontend.sarif", trivy.container(await self.build(context.directory("frontend"))).report("sarif"))
        )
        return directory

    @function
    def frontend(self, context: dagger.Directory) -> dagger.Container:
        """Returns a frontend container built with the given context and params."""
        return (
            dag.container()
            .with_env_variable("API_URL", "http://api:8081")
            .with_env_variable("REDIS_URL", "redis://redisd:6379/0?decode_responses=True")
            .build(context)
        )

    @function
    def backend(self, context: dagger.Directory) -> dagger.Container:
        """Returns a backend container built with the given context, params and service bindings."""
        return (
            dag.container()
            .with_env_variable("MAX_WORKERS", "1")
            .with_env_variable("PORT", "8081")
            .with_env_variable("REDIS", "redis://redisd:6379/0?decode_responses=True")
            .with_env_variable("SKIP_EMAIL_VERIFICATION", "True")
            .with_env_variable("DB_URL", "postgresql://postgres:classquiz@postgresd:5432/classquiz")
            .with_env_variable("MAIL_ADDRESS", "some@example.org")
            .with_env_variable("MAIL_PASSWORD", "some@example.org")
            .with_env_variable("MAIL_USERNAME", "some@example.org")
            .with_env_variable("MAIL_SERVER", "some.example.org")
            .with_env_variable("MAIL_PORT", "525")
            .with_env_variable("SECRET_KEY", "secret")
            .with_env_variable("MEILISEARCH_URL", "http://meilisearchd:7700")
            .with_env_variable("STORAGE_BACKEND", "local")
            .with_env_variable("STORAGE_PATH", "/app/data")
            .with_service_binding("postgresd", self.postgres())
            .with_service_binding("meilisearchd", self.meilisearch())
            .with_service_binding("redisd", self.redis())
            .build(context)
        )

    @function
    def redis(self) -> dagger.Service:
        """Returns a redis service from a container built with the given params."""
        return (
            dag.container()
            .from_("redis:alpine")
            .with_exposed_port(6379)
            .as_service()
        )

    @function
    def postgres(self) -> dagger.Service:
        """Returns a postgres database service from a container built with the given params."""
        return (
            dag.container()
            .from_("postgres:14-alpine")
            .with_env_variable("POSTGRES_PASSWORD", "classquiz")
            .with_env_variable("POSTGRES_DB", "classquiz")
            .with_env_variable("POSTGRES_USER", "postgres")
            .with_exposed_port(5432)
            .as_service()
        )

    @function
    def meilisearch(self) -> dagger.Service:
        """Returns a meilisearch service from a container built with the given params."""
        return (
            dag.container()
            .from_("getmeili/meilisearch:v0.28.0")
            .with_exposed_port(7700)
            .as_service()
        )

    @function
    def proxy(self, context: dagger.Directory, proxy_config: dagger.File) -> dagger.Service:
        """Returns a caddy proxy service encapsulating the front and backend services. This service must be bound to port 8000 in order to match some hard coded configuration: --ports 8000:8080"""
        return (
            dag.container()
            .from_("caddy:alpine")
            .with_service_binding("frontend", self.frontend(context.directory("frontend")).as_service())
            .with_service_binding("api", self.backend(context).as_service())
            .with_file("/etc/caddy/Caddyfile", proxy_config)
            .with_exposed_port(8080)
            .as_service()
        )

dagger.yml:

name: CI Pipeline
on: [ push ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: test and scan
        uses: dagger/dagger-for-github@v8
        with:
          version: latest
          verb: call
          module: .
          args: ci --context=. export --path=.