From 7a0281ad6e6b5b01a540ce6004ec96db238a85e2 Mon Sep 17 00:00:00 2001 From: Sean Murphy Date: Tue, 6 Feb 2024 11:50:48 +0100 Subject: [PATCH] chore(initial-commit): initial commit of all content --- .github/workflows/build-and-deploy.yaml | 24 ++ README.md | 32 +++ default.nix | 10 + flake.lock | 283 ++++++++++++++++++++++++ flake.nix | 144 ++++++++++++ pyproject.toml | 28 +++ scripts/build_and_push_image.sh | 14 ++ shell.nix | 10 + src/app.py | 14 ++ 9 files changed, 559 insertions(+) create mode 100644 .github/workflows/build-and-deploy.yaml create mode 100644 README.md create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 pyproject.toml create mode 100755 scripts/build_and_push_image.sh create mode 100644 shell.nix create mode 100644 src/app.py diff --git a/.github/workflows/build-and-deploy.yaml b/.github/workflows/build-and-deploy.yaml new file mode 100644 index 0000000..bd6a79f --- /dev/null +++ b/.github/workflows/build-and-deploy.yaml @@ -0,0 +1,24 @@ +--- +name: "Build and deploy site - cuda edition" +on: # yamllint disable-line rule:truthy + pull_request: + push: +jobs: + build: + # when running on self-hosted, uncomment the following and comment out the subsequent line + runs-on: [ self-hosted, nixos, nvidia-545 ] + # runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # when running on a self-hosted nixos system, I removed the following 2 uses... + #- uses: DeterminateSystems/nix-installer-action@main + #- uses: DeterminateSystems/magic-nix-cache-action@main + - run: | + scripts/build_and_push_image.sh + env: + # this secret must be defined and available to the runner; it's assumed it + # has write priviliges on a docker registry + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} + if: github.ref == 'refs/heads/main' diff --git a/README.md b/README.md new file mode 100644 index 0000000..f26daab --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# nix-container-build-gha + +This is a simple testing repo I used to understand how to build python environments in github actions +with nix. I wrote up my thoughts in [this medium post](https://medium.com/@seanrmurphy/building-container-images-using-nix-and-github-actions-ba548ab9080d). + +This is the public version of the repo which anyone can look through; I also maintain a private version +which is linked to some self hosted github runners - I don't want to link the public version to any such +runners. + +There are comments in the code which give some pointers on how things work - feel free to look around. + +## The python application + +The python application was taken from [this repo](https://github.com/mitchellh/flask-nix-example) created by Mitchell Hashimoto - it is a simple flask application. + +I have included a couple of unecessary dependencies in the `pyproject.toml` just to understand how these are +handled (`torch`, `jupyter` and `beautifulsoup4`). They are available in the resulting python environment but not used by the application. + +## Working locally + +This assumes you have a sensible nix configuration and are comfortable using flakes. + +- `nix build` will build the application and put the content in the `result` directory +- `nix build .#ociApplicationImage` will build a container image which runs the application - the resulting container image is a gzip'd tarball in the `result` directory which can be imported to docker using `docker load < result` +- `nix build .#ociPackageImage` will build a container image which contains the python environment but launches bash; run python within bash and you can import the dependencies +- `nix run` will run the application without building a container image + +## Using the github actions + +The github action was essentially copied from [this repo](https://github.com/wagdav/thewagner.net). + +It requires a token called DOCKER_ACCESS_TOKEN to access a docker repository and push the resulting container image there. This repo uses the standard github runners but in the private variant, I was using a self-hosted runner (there are a couple of comments in the github action definition which highlight the small differences). diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..2cccff2 --- /dev/null +++ b/default.nix @@ -0,0 +1,10 @@ +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).defaultNix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5c6b37e --- /dev/null +++ b/flake.lock @@ -0,0 +1,283 @@ +{ + "nodes": { + "crane": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "nixpkgs": [ + "pyproject-nix", + "mdbook-nixdoc", + "nixpkgs" + ], + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1688772518, + "narHash": "sha256-ol7gZxwvgLnxNSZwFTDJJ49xVY5teaSvF7lzlo3YQfM=", + "owner": "ipetkov", + "repo": "crane", + "rev": "8b08e96c9af8c6e3a2b69af5a7fa168750fcf88e", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "pyproject-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1701473968, + "narHash": "sha256-YcVE5emp1qQ8ieHUnxt1wCZCC3ZfAS+SRRWZ2TMda7E=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "34fed993f1674c8d06d58b37ce1e0fe5eebcb9f5", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-root": { + "locked": { + "lastModified": 1692742795, + "narHash": "sha256-f+Y0YhVCIJ06LemO+3Xx00lIcqQxSKJHXT/yk1RTKxw=", + "owner": "srid", + "repo": "flake-root", + "rev": "d9a70d9c7a5fd7f3258ccf48da9335e9b47c3937", + "type": "github" + }, + "original": { + "owner": "srid", + "repo": "flake-root", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1687709756, + "narHash": "sha256-Y5wKlQSkgEK2weWdOu4J3riRd+kV/VCgHsqLNTTWQ/0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "dbabf0ca0c0c4bce6ea5eaf65af5cb694d2082c7", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "mdbook-nixdoc": { + "inputs": { + "crane": "crane", + "flake-parts": [ + "pyproject-nix", + "flake-parts" + ], + "nix-github-actions": [ + "pyproject-nix", + "nix-github-actions" + ], + "nixpkgs": [ + "pyproject-nix", + "nixpkgs" + ], + "treefmt-nix": [ + "pyproject-nix", + "treefmt-nix" + ] + }, + "locked": { + "lastModified": 1690987907, + "narHash": "sha256-p9iXhgEhV4Z5DPlS+9HlMOFBAc8v2B9SR5qEgOy+Qos=", + "owner": "adisbladis", + "repo": "mdbook-nixdoc", + "rev": "8563c0a3abe68f046a890846df406fcbaeb3d5b5", + "type": "github" + }, + "original": { + "owner": "adisbladis", + "repo": "mdbook-nixdoc", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "pyproject-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1701208414, + "narHash": "sha256-xrQ0FyhwTZK6BwKhahIkUVZhMNk21IEI1nUcWSONtpo=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "93e39cc1a087d65bcf7a132e75a650c44dd2b734", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1706098335, + "narHash": "sha256-r3dWjT8P9/Ah5m5ul4WqIWD8muj5F+/gbCdjiNVBKmU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a77ab169a83a4175169d78684ddd2e54486ac651", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-23.11", + "type": "indirect" + } + }, + "proc-flake": { + "locked": { + "lastModified": 1692742849, + "narHash": "sha256-Nv8SOX+O6twFfPnA9BfubbPLZpqc+UeK6JvIWnWkdb0=", + "owner": "srid", + "repo": "proc-flake", + "rev": "25291b6e3074ad5dd573c1cb7d96110a9591e10f", + "type": "github" + }, + "original": { + "owner": "srid", + "repo": "proc-flake", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "flake-parts": "flake-parts", + "flake-root": "flake-root", + "mdbook-nixdoc": "mdbook-nixdoc", + "nix-github-actions": "nix-github-actions", + "nixpkgs": [ + "nixpkgs" + ], + "proc-flake": "proc-flake", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1705085606, + "narHash": "sha256-Hj+uqq/VXlKQwyKs85Jd0JcP49lI09Q2PTNX4S2FIGE=", + "owner": "nix-community", + "repo": "pyproject.nix", + "rev": "1e3581327260c4b4633f7b2ae923ee039f4446fa", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "pyproject-nix": "pyproject-nix" + } + }, + "rust-overlay": { + "inputs": { + "flake-utils": [ + "pyproject-nix", + "mdbook-nixdoc", + "crane", + "flake-utils" + ], + "nixpkgs": [ + "pyproject-nix", + "mdbook-nixdoc", + "crane", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1688351637, + "narHash": "sha256-CLTufJ29VxNOIZ8UTg0lepsn3X03AmopmaLTTeHDCL4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "f9b92316727af9e6c7fee4a761242f7f46880329", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "pyproject-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1702979157, + "narHash": "sha256-RnFBbLbpqtn4AoJGXKevQMCGhra4h6G2MPcuTSZZQ+g=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "2961375283668d867e64129c22af532de8e77734", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c1bb438 --- /dev/null +++ b/flake.nix @@ -0,0 +1,144 @@ +{ + description = "A basic flake using pyproject.toml project metadata - builds a CUDA docker image"; + + inputs = { + + pyproject-nix = { + url = "github:nix-community/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixpkgs = { + url = "nixpkgs/nixos-23.11"; + }; + + }; + + outputs = { self, nixpkgs, pyproject-nix, ... }: + let + system = "x86_64-linux"; + revision = "${self.shortRev or "dirty"}"; + + # Loads pyproject.toml into a high-level project representation + # Do you notice how this is not tied to any `system` attribute or package sets? + # That is because `project` refers to a pure data representation. + project = pyproject-nix.lib.project.loadPyproject { + # Read & unmarshal pyproject.toml relative to this project root. + # projectRoot is also used to set `src` for renderers such as buildPythonPackage. + projectRoot = ./.; + }; + + # This example is only using x86_64-linux + + # to add cuda stuff + pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + cudaSupport = true; + cudaCapabilities = [ "7.5" "8.6" ]; + cudaForwardCompat = false; + }; + }; + + # We are using the default nixpkgs Python3 interpreter & package set. + # + # This means that you are purposefully ignoring: + # - Version bounds + # - Dependency sources (meaning local path dependencies won't resolve to the local path) + # + # To use packages from local sources see "Overriding Python packages" in the nixpkgs manual: + # https://nixos.org/manual/nixpkgs/stable/#reference + # + # Or use an overlay generator such as pdm2nix: + # https://github.com/adisbladis/pdm2nix + python = pkgs.python3; + + # Returns a function that can be passed to `python.withPackages` + arg = project.renderers.withPackages { inherit python; }; + + # Returns a wrapped environment (virtualenv like) with all our packages + pythonEnv = python.withPackages arg; + + # Returns an attribute set that can be passed to `pkgs.buildPythonPackage`. + attrs = project.renderers.buildPythonPackage { inherit python; }; + + # Pass attributes to buildPythonPackage. + # Here is a good spot to add on any missing or custom attributes. + pythonPackage = python.pkgs.buildPythonPackage (attrs); + + buildApplicationImage = + let + # we must somehow know something about the application here... + port = "5000"; + in + pkgs.dockerTools.buildLayeredImage + { + name = "nix-build-application-image-cuda"; + tag = revision; + contents = [ pythonEnv pkgs.bash pkgs.findutils pkgs.uutils-coreutils-noprefix ]; + config = { + Cmd = [ + # must know that the name of the application is app, as defined in the pyproject.toml file + # in this case, the app is not using CUDA but it can... + "${pythonPackage}/bin/app" + ]; + ExposedPorts = { + "${port}/tcp" = { }; + }; + }; + }; + + buildPackageImage = + pkgs.dockerTools.buildLayeredImage + { + name = "nix-build-package-image-cuda"; + tag = revision; + # the pythonEnv is really just required here...the rest are handy utilities + contents = [ pythonEnv pkgs.bash pkgs.findutils pkgs.uutils-coreutils-noprefix ]; + config = { + # just run bash when the container starts; note that bash is only installed if the package is specified above + Cmd = [ + "${pkgs.bash}/bin/bash" + ]; + # this is probably too static but it works + Env = [ "LD_LIBRARY_PATH=/usr/lib64" ]; + }; + + # when building a container image this way, no /tmp is created; this is required to make libs + # available from the host system to the container + # note that the leading slash MUST NOT be there; otherwise this does not work + fakeRootCommands = '' + #!${pkgs.runtimeShell} + mkdir -p tmp + chmod -R 1777 tmp + ''; + }; + in + { + + # Create a development shell containing dependencies from `pyproject.toml` + devShells.${system}.default = + # Create a devShell like normal. + pkgs.mkShell { + packages = [ pythonEnv pkgs.skopeo ]; + }; + + packages.${system} = { + # default is built with `nix build` + default = pythonPackage; + + # built with `nix build .#ociPackageImage` + # the result is a gzip'd tarball - it can be imported to docker with `docker load < result` + ociPackageImage = buildPackageImage; + + # built with `nix build .#ociApplicationImage` + # the result is a gzip'd tarball - it can be imported to docker with `docker load < result` + ociApplicationImage = buildApplicationImage; + }; + + # for `nix run` + app.${system}.default = pythonPackage; + + }; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d01ef80 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +# This content was taken from this repo: https://github.com/mitchellh/flask-nix-example + +[project] +name = "flask-example" +version = "0.1.0" +description = "" +authors = [ + { name = "Mitchell Hashimoto", email = "" }, +] +readme = "README.md" +packages = [{include = "src"}] +requires-python = ">=3.10" +license = {text = "MIT"} +# Flask is necessary to run the application - torch, jupyter and beautifulsoup4 are included to understand how the plumbing works +dependencies = [ + "Flask>=2.2.3", + "torch>=2.0.0", + # "jupyter>=1.0.0", + # "beautifulsoup4", +] + +[project.scripts] +# we don't include src here because the packages directive above indicates that only content within the src file should be included +app = 'app:main' + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" diff --git a/scripts/build_and_push_image.sh b/scripts/build_and_push_image.sh new file mode 100755 index 0000000..ccaea4c --- /dev/null +++ b/scripts/build_and_push_image.sh @@ -0,0 +1,14 @@ +#! /usr/bin/env nix-shell +#! nix-shell ../shell.nix -i bash + +set -eu + +OCI_ARCHIVE=$(nix-build --no-out-link -A packages.x86_64-linux.ociApplicationImage) +DOCKER_REPOSITORY="docker://seanrmurphy/nix-container-build-gha-cuda" +DOCKER_USERNAME="seanrmurphy" + +if [ -z ${DOCKER_ACCESS_TOKEN+x} ]; then + skopeo --insecure-policy copy "docker-archive:${OCI_ARCHIVE}" "$DOCKER_REPOSITORY" +else + skopeo --insecure-policy copy --dest-creds="$DOCKER_USERNAME:$DOCKER_ACCESS_TOKEN" "docker-archive:${OCI_ARCHIVE}" "$DOCKER_REPOSITORY" +fi diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..6234bb4 --- /dev/null +++ b/shell.nix @@ -0,0 +1,10 @@ +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).shellNix diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..704f312 --- /dev/null +++ b/src/app.py @@ -0,0 +1,14 @@ +# From mitchellh example +from flask import Flask + +app = Flask(__name__) + +@app.route("/") +def hello_world(): + return "

Hello, World!

" + +def main(): + app.run() + +if __name__ == '__main__': + main()