Merge changes from topic "releng" into main
* changes: releng: add prod environment, ready for release releng: automatically figure out if we should tag latest for docker releng: support multiarch docker images manual: rewrite the docker guide now that we have images Rewrite docker to be sensible and smaller Implement docker upload in the releng tools
This commit is contained in:
commit
8a3d063a49
|
@ -1,64 +1,62 @@
|
||||||
# Using Lix within Docker
|
# Using Lix within Docker
|
||||||
|
|
||||||
Currently the Lix project doesn't ship docker images. However, we have the infrastructure to do it, it's just not yet been done. See https://git.lix.systems/lix-project/lix/issues/252
|
Lix is available on the following two container registries:
|
||||||
|
- [ghcr.io/lix-project/lix](https://ghcr.io/lix-project/lix)
|
||||||
<!--
|
- [git.lix.systems/lix-project/lix](https://git.lix.systems/lix-project/-/packages/container/lix)
|
||||||
|
|
||||||
To run the latest stable release of Lix with Docker run the following command:
|
To run the latest stable release of Lix with Docker run the following command:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ docker run -ti nixos/nix
|
~ » sudo podman run -it ghcr.io/lix-project/lix:latest
|
||||||
Unable to find image 'nixos/nix:latest' locally
|
Trying to pull ghcr.io/lix-project/lix:latest...
|
||||||
latest: Pulling from nixos/nix
|
|
||||||
5843afab3874: Pull complete
|
bash-5.2# nix --version
|
||||||
b52bf13f109c: Pull complete
|
nix (Lix, like Nix) 2.90.0
|
||||||
1e2415612aa3: Pull complete
|
|
||||||
Digest: sha256:27f6e7f60227e959ee7ece361f75d4844a40e1cc6878b6868fe30140420031ff
|
|
||||||
Status: Downloaded newer image for nixos/nix:latest
|
|
||||||
35ca4ada6e96:/# nix --version
|
|
||||||
nix (Nix) 2.3.12
|
|
||||||
35ca4ada6e96:/# exit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# What is included in Lix's Docker image?
|
# What is included in Lix's Docker image?
|
||||||
|
|
||||||
The official Docker image is created using `pkgs.dockerTools.buildLayeredImage`
|
The official Docker image is created using [nix2container]
|
||||||
(and not with `Dockerfile` as it is usual with Docker images). You can still
|
(and not with `Dockerfile` as it is usual with Docker images). You can still
|
||||||
base your custom Docker image on it as you would do with any other Docker
|
base your custom Docker image on it as you would do with any other Docker
|
||||||
image.
|
image.
|
||||||
|
|
||||||
The Docker image is also not based on any other image and includes minimal set
|
[nix2container]: https://github.com/nlewo/nix2container
|
||||||
of runtime dependencies that are required to use Lix:
|
|
||||||
|
|
||||||
- pkgs.nix
|
The Docker image is also not based on any other image and includes the nixpkgs
|
||||||
- pkgs.bashInteractive
|
that Lix was built with along with a minimal set of tools in the system profile:
|
||||||
- pkgs.coreutils-full
|
|
||||||
- pkgs.gnutar
|
- bashInteractive
|
||||||
- pkgs.gzip
|
- cacert.out
|
||||||
- pkgs.gnugrep
|
- coreutils-full
|
||||||
- pkgs.which
|
- curl
|
||||||
- pkgs.curl
|
- findutils
|
||||||
- pkgs.less
|
- gitMinimal
|
||||||
- pkgs.wget
|
- gnugrep
|
||||||
- pkgs.man
|
- gnutar
|
||||||
- pkgs.cacert.out
|
- gzip
|
||||||
- pkgs.findutils
|
- iana-etc
|
||||||
|
- less
|
||||||
|
- libxml2
|
||||||
|
- lix
|
||||||
|
- man
|
||||||
|
- openssh
|
||||||
|
- sqlite
|
||||||
|
- wget
|
||||||
|
- which
|
||||||
|
|
||||||
# Docker image with the latest development version of Lix
|
# Docker image with the latest development version of Lix
|
||||||
|
|
||||||
To get the latest image that was built by [Hydra](https://hydra.nixos.org) run
|
FIXME: There are not currently images of development versions of Lix. Tracking issue: https://git.lix.systems/lix-project/lix/issues/381
|
||||||
the following command:
|
|
||||||
|
You can build a Docker image from source yourself and copy it to either:
|
||||||
|
|
||||||
|
Podman: `nix run '.#dockerImage.copyTo' containers-storage:lix`
|
||||||
|
|
||||||
|
Docker: `nix run '.#dockerImage.copyToDockerDaemon'`
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ curl -L https://hydra.nixos.org/job/nix/master/dockerImage.x86_64-linux/latest/download/1 | docker load
|
$ docker run -ti lix
|
||||||
$ docker run -ti nix:2.5pre20211105
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also build a Docker image from source yourself:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ nix build ./\#hydraJobs.dockerImage.x86_64-linux
|
|
||||||
$ docker load -i ./result/image.tar.gz
|
|
||||||
$ docker run -ti nix:2.5pre20211105
|
|
||||||
```
|
|
||||||
-->
|
|
||||||
|
|
142
docker.nix
142
docker.nix
|
@ -1,5 +1,8 @@
|
||||||
{
|
{
|
||||||
pkgs ? import <nixpkgs> { },
|
pkgs ? import <nixpkgs> { },
|
||||||
|
# Git commit ID, if available
|
||||||
|
lixRevision ? null,
|
||||||
|
nix2container,
|
||||||
lib ? pkgs.lib,
|
lib ? pkgs.lib,
|
||||||
name ? "lix",
|
name ? "lix",
|
||||||
tag ? "latest",
|
tag ? "latest",
|
||||||
|
@ -12,26 +15,51 @@
|
||||||
flake-registry ? null,
|
flake-registry ? null,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
defaultPkgs =
|
layerContents = with pkgs; [
|
||||||
with pkgs;
|
# pulls in glibc and openssl, about 60MB
|
||||||
[
|
{ contents = [ coreutils-full ]; }
|
||||||
nix
|
# some stuff that is low in the closure graph and small ish, mostly to make
|
||||||
|
# incremental lix updates cheaper
|
||||||
|
{
|
||||||
|
contents = [
|
||||||
|
curl
|
||||||
|
libxml2
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
}
|
||||||
|
# 50MB of git
|
||||||
|
{ contents = [ gitMinimal ]; }
|
||||||
|
# 144MB of nixpkgs
|
||||||
|
{
|
||||||
|
contents = [ channel ];
|
||||||
|
inProfile = false;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# These packages are left to be auto layered by nix2container, since it is
|
||||||
|
# less critical that they get layered sensibly and they tend to not be deps
|
||||||
|
# of anything in particular
|
||||||
|
autoLayered = with pkgs; [
|
||||||
bashInteractive
|
bashInteractive
|
||||||
coreutils-full
|
|
||||||
gnutar
|
gnutar
|
||||||
gzip
|
gzip
|
||||||
gnugrep
|
gnugrep
|
||||||
which
|
which
|
||||||
curl
|
|
||||||
less
|
less
|
||||||
wget
|
wget
|
||||||
man
|
man
|
||||||
cacert.out
|
cacert.out
|
||||||
findutils
|
findutils
|
||||||
iana-etc
|
iana-etc
|
||||||
git
|
|
||||||
openssh
|
openssh
|
||||||
]
|
nix
|
||||||
|
];
|
||||||
|
|
||||||
|
defaultPkgs =
|
||||||
|
lib.lists.flatten (
|
||||||
|
map (x: if !(x ? inProfile) || x.inProfile then x.contents else [ ]) layerContents
|
||||||
|
)
|
||||||
|
++ autoLayered
|
||||||
++ extraPkgs;
|
++ extraPkgs;
|
||||||
|
|
||||||
users =
|
users =
|
||||||
|
@ -139,16 +167,17 @@ let
|
||||||
))
|
))
|
||||||
+ "\n";
|
+ "\n";
|
||||||
|
|
||||||
baseSystem =
|
|
||||||
let
|
|
||||||
nixpkgs = pkgs.path;
|
nixpkgs = pkgs.path;
|
||||||
channel = pkgs.runCommand "channel-nixos" { inherit bundleNixpkgs; } ''
|
channel = pkgs.runCommand "channel-nixpkgs" { } ''
|
||||||
mkdir $out
|
mkdir $out
|
||||||
if [ "$bundleNixpkgs" ]; then
|
${lib.optionalString bundleNixpkgs ''
|
||||||
ln -s ${nixpkgs} $out/nixpkgs
|
ln -s ${nixpkgs} $out/nixpkgs
|
||||||
echo "[]" > $out/manifest.nix
|
echo "[]" > $out/manifest.nix
|
||||||
fi
|
''}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
baseSystem =
|
||||||
|
let
|
||||||
rootEnv = pkgs.buildPackages.buildEnv {
|
rootEnv = pkgs.buildPackages.buildEnv {
|
||||||
name = "root-profile-env";
|
name = "root-profile-env";
|
||||||
paths = defaultPkgs;
|
paths = defaultPkgs;
|
||||||
|
@ -187,7 +216,7 @@ let
|
||||||
profile = pkgs.buildPackages.runCommand "user-environment" { } ''
|
profile = pkgs.buildPackages.runCommand "user-environment" { } ''
|
||||||
mkdir $out
|
mkdir $out
|
||||||
cp -a ${rootEnv}/* $out/
|
cp -a ${rootEnv}/* $out/
|
||||||
ln -s ${manifest} $out/manifest.nix
|
ln -sf ${manifest} $out/manifest.nix
|
||||||
'';
|
'';
|
||||||
flake-registry-path =
|
flake-registry-path =
|
||||||
if (flake-registry == null) then
|
if (flake-registry == null) then
|
||||||
|
@ -236,6 +265,7 @@ let
|
||||||
ln -s /nix/var/nix/profiles/share $out/usr/
|
ln -s /nix/var/nix/profiles/share $out/usr/
|
||||||
|
|
||||||
mkdir -p $out/nix/var/nix/gcroots
|
mkdir -p $out/nix/var/nix/gcroots
|
||||||
|
ln -s /nix/var/nix/profiles $out/nix/var/nix/gcroots/profiles
|
||||||
|
|
||||||
mkdir $out/tmp
|
mkdir $out/tmp
|
||||||
|
|
||||||
|
@ -248,14 +278,14 @@ let
|
||||||
mkdir -p $out/nix/var/nix/profiles/per-user/root
|
mkdir -p $out/nix/var/nix/profiles/per-user/root
|
||||||
|
|
||||||
ln -s ${profile} $out/nix/var/nix/profiles/default-1-link
|
ln -s ${profile} $out/nix/var/nix/profiles/default-1-link
|
||||||
ln -s $out/nix/var/nix/profiles/default-1-link $out/nix/var/nix/profiles/default
|
ln -s /nix/var/nix/profiles/default-1-link $out/nix/var/nix/profiles/default
|
||||||
ln -s /nix/var/nix/profiles/default $out/root/.nix-profile
|
ln -s /nix/var/nix/profiles/default $out/root/.nix-profile
|
||||||
|
|
||||||
ln -s ${channel} $out/nix/var/nix/profiles/per-user/root/channels-1-link
|
ln -s ${channel} $out/nix/var/nix/profiles/per-user/root/channels-1-link
|
||||||
ln -s $out/nix/var/nix/profiles/per-user/root/channels-1-link $out/nix/var/nix/profiles/per-user/root/channels
|
ln -s /nix/var/nix/profiles/per-user/root/channels-1-link $out/nix/var/nix/profiles/per-user/root/channels
|
||||||
|
|
||||||
mkdir -p $out/root/.nix-defexpr
|
mkdir -p $out/root/.nix-defexpr
|
||||||
ln -s $out/nix/var/nix/profiles/per-user/root/channels $out/root/.nix-defexpr/channels
|
ln -s /nix/var/nix/profiles/per-user/root/channels $out/root/.nix-defexpr/channels
|
||||||
echo "${channelURL} ${channelName}" > $out/root/.nix-channels
|
echo "${channelURL} ${channelName}" > $out/root/.nix-channels
|
||||||
|
|
||||||
mkdir -p $out/bin $out/usr/bin
|
mkdir -p $out/bin $out/usr/bin
|
||||||
|
@ -273,21 +303,35 @@ let
|
||||||
ln -s $globalFlakeRegistryPath $out/nix/var/nix/gcroots/auto/$rootName
|
ln -s $globalFlakeRegistryPath $out/nix/var/nix/gcroots/auto/$rootName
|
||||||
'')
|
'')
|
||||||
);
|
);
|
||||||
in
|
|
||||||
pkgs.dockerTools.buildLayeredImageWithNixDb {
|
layers = builtins.foldl' (
|
||||||
|
layersList: el:
|
||||||
|
let
|
||||||
|
layer = nix2container.buildLayer {
|
||||||
|
deps = el.contents;
|
||||||
|
layers = layersList;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
layersList ++ [ layer ]
|
||||||
|
) [ ] layerContents;
|
||||||
|
|
||||||
|
image = nix2container.buildImage {
|
||||||
|
|
||||||
inherit name tag maxLayers;
|
inherit name tag maxLayers;
|
||||||
|
|
||||||
contents = [ baseSystem ];
|
inherit layers;
|
||||||
|
|
||||||
extraCommands = ''
|
copyToRoot = [ baseSystem ];
|
||||||
rm -rf nix-support
|
|
||||||
ln -s /nix/var/nix/profiles nix/var/nix/gcroots/profiles
|
initializeNixDatabase = true;
|
||||||
'';
|
|
||||||
fakeRootCommands = ''
|
perms = [
|
||||||
chmod 1777 tmp
|
{
|
||||||
chmod 1777 var/tmp
|
path = baseSystem;
|
||||||
'';
|
regex = "(/var)?/tmp";
|
||||||
|
mode = "1777";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
Cmd = [ "/root/.nix-profile/bin/bash" ];
|
Cmd = [ "/root/.nix-profile/bin/bash" ];
|
||||||
|
@ -311,5 +355,47 @@ pkgs.dockerTools.buildLayeredImageWithNixDb {
|
||||||
"NIX_SSL_CERT_FILE=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt"
|
"NIX_SSL_CERT_FILE=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt"
|
||||||
"NIX_PATH=/nix/var/nix/profiles/per-user/root/channels:/root/.nix-defexpr/channels"
|
"NIX_PATH=/nix/var/nix/profiles/per-user/root/channels:/root/.nix-defexpr/channels"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
Labels = {
|
||||||
|
"org.opencontainers.image.title" = "Lix";
|
||||||
|
"org.opencontainers.image.source" = "https://git.lix.systems/lix-project/lix";
|
||||||
|
"org.opencontainers.image.vendor" = "Lix project";
|
||||||
|
"org.opencontainers.image.version" = pkgs.nix.version;
|
||||||
|
"org.opencontainers.image.description" = "Minimal Lix container image, with some batteries included.";
|
||||||
|
} // lib.optionalAttrs (lixRevision != null) { "org.opencontainers.image.revision" = lixRevision; };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Docker image for Lix. This is built with nix2container; see that project's README for details";
|
||||||
|
longDescription = ''
|
||||||
|
Docker image for Lix, built with nix2container.
|
||||||
|
To copy it to your docker daemon, nix run .#dockerImage.copyToDockerDaemon
|
||||||
|
To copy it to podman, nix run .#dockerImage.copyTo containers-storage:lix
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
image
|
||||||
|
// {
|
||||||
|
# We don't ship the tarball as the default output because it is a strange thing to want imo
|
||||||
|
tarball =
|
||||||
|
pkgs.buildPackages.runCommand "docker-image-tarball-${pkgs.nix.version}"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [ pkgs.buildPackages.bubblewrap ];
|
||||||
|
meta.description = "Docker image tarball with Lix for ${pkgs.system}";
|
||||||
|
}
|
||||||
|
''
|
||||||
|
mkdir -p $out/nix-support
|
||||||
|
image=$out/image.tar
|
||||||
|
# bwrap for foolish temp dir selection code that forces /var/tmp:
|
||||||
|
# https://github.com/containers/skopeo.git/blob/60ee543f7f7c242f46cc3a7541d9ac8ab1c89168/vendor/github.com/containers/image/v5/internal/tmpdir/tmpdir.go#L15-L18
|
||||||
|
mkdir -p $TMPDIR/fake-var/tmp
|
||||||
|
args=(--unshare-user --bind "$TMPDIR/fake-var" /var)
|
||||||
|
for dir in /*; do
|
||||||
|
args+=(--dev-bind "/$dir" "/$dir")
|
||||||
|
done
|
||||||
|
bwrap ''${args[@]} -- ${lib.getExe image.copyTo} docker-archive:$image
|
||||||
|
gzip $image
|
||||||
|
echo "file binary-dist $image" >> $out/nix-support/hydra-build-products
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
17
flake.lock
17
flake.lock
|
@ -16,6 +16,22 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nix2container": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1712990762,
|
||||||
|
"narHash": "sha256-hO9W3w7NcnYeX8u8cleHiSpK2YJo7ecarFTUlbybl7k=",
|
||||||
|
"owner": "nlewo",
|
||||||
|
"repo": "nix2container",
|
||||||
|
"rev": "20aad300c925639d5d6cbe30013c8357ce9f2a2e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nlewo",
|
||||||
|
"repo": "nix2container",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1715123187,
|
"lastModified": 1715123187,
|
||||||
|
@ -67,6 +83,7 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-compat": "flake-compat",
|
"flake-compat": "flake-compat",
|
||||||
|
"nix2container": "nix2container",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"nixpkgs-regression": "nixpkgs-regression",
|
"nixpkgs-regression": "nixpkgs-regression",
|
||||||
"pre-commit-hooks": "pre-commit-hooks"
|
"pre-commit-hooks": "pre-commit-hooks"
|
||||||
|
|
19
flake.nix
19
flake.nix
|
@ -8,6 +8,10 @@
|
||||||
url = "github:cachix/git-hooks.nix";
|
url = "github:cachix/git-hooks.nix";
|
||||||
flake = false;
|
flake = false;
|
||||||
};
|
};
|
||||||
|
nix2container = {
|
||||||
|
url = "github:nlewo/nix2container";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
flake-compat = {
|
flake-compat = {
|
||||||
url = "github:edolstra/flake-compat";
|
url = "github:edolstra/flake-compat";
|
||||||
flake = false;
|
flake = false;
|
||||||
|
@ -20,6 +24,7 @@
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
nixpkgs-regression,
|
nixpkgs-regression,
|
||||||
pre-commit-hooks,
|
pre-commit-hooks,
|
||||||
|
nix2container,
|
||||||
flake-compat,
|
flake-compat,
|
||||||
}:
|
}:
|
||||||
|
|
||||||
|
@ -330,19 +335,13 @@
|
||||||
dockerImage =
|
dockerImage =
|
||||||
let
|
let
|
||||||
pkgs = nixpkgsFor.${system}.native;
|
pkgs = nixpkgsFor.${system}.native;
|
||||||
image = import ./docker.nix {
|
nix2container' = import nix2container { inherit pkgs system; };
|
||||||
|
in
|
||||||
|
import ./docker.nix {
|
||||||
inherit pkgs;
|
inherit pkgs;
|
||||||
|
nix2container = nix2container'.nix2container;
|
||||||
tag = pkgs.nix.version;
|
tag = pkgs.nix.version;
|
||||||
};
|
};
|
||||||
in
|
|
||||||
pkgs.runCommand "docker-image-tarball-${pkgs.nix.version}"
|
|
||||||
{ meta.description = "Docker image with Lix for ${system}"; }
|
|
||||||
''
|
|
||||||
mkdir -p $out/nix-support
|
|
||||||
image=$out/image.tar.gz
|
|
||||||
ln -s ${image} $image
|
|
||||||
echo "file binary-dist $image" >> $out/nix-support/hydra-build-products
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
// builtins.listToAttrs (
|
// builtins.listToAttrs (
|
||||||
map (crossSystem: {
|
map (crossSystem: {
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
pkg-config,
|
pkg-config,
|
||||||
python3,
|
python3,
|
||||||
rapidcheck,
|
rapidcheck,
|
||||||
|
skopeo,
|
||||||
sqlite,
|
sqlite,
|
||||||
toml11,
|
toml11,
|
||||||
util-linuxMinimal ? utillinuxMinimal,
|
util-linuxMinimal ? utillinuxMinimal,
|
||||||
|
@ -416,6 +417,8 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
p: [
|
p: [
|
||||||
p.yapf
|
p.yapf
|
||||||
p.python-frontmatter
|
p.python-frontmatter
|
||||||
|
p.requests
|
||||||
|
p.xdg-base-dirs
|
||||||
(p.toPythonModule xonsh-unwrapped)
|
(p.toPythonModule xonsh-unwrapped)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
@ -452,6 +455,8 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
lib.optional (stdenv.cc.isClang && hostPlatform == buildPlatform) clang-tools_llvm
|
lib.optional (stdenv.cc.isClang && hostPlatform == buildPlatform) clang-tools_llvm
|
||||||
++ [
|
++ [
|
||||||
pythonEnv
|
pythonEnv
|
||||||
|
# docker image tool
|
||||||
|
skopeo
|
||||||
just
|
just
|
||||||
nixfmt
|
nixfmt
|
||||||
# Load-bearing order. Must come before clang-unwrapped below, but after clang_tools above.
|
# Load-bearing order. Must come before clang-unwrapped below, but after clang_tools above.
|
||||||
|
|
|
@ -2,11 +2,30 @@ from xonsh.main import setup
|
||||||
setup()
|
setup()
|
||||||
del setup
|
del setup
|
||||||
|
|
||||||
from releng import environment
|
import logging
|
||||||
from releng import create_release
|
|
||||||
from releng import keys
|
from . import environment
|
||||||
from releng import version
|
from . import create_release
|
||||||
from releng import cli
|
from . import keys
|
||||||
|
from . import version
|
||||||
|
from . import cli
|
||||||
|
from . import docker
|
||||||
|
from . import docker_assemble
|
||||||
|
from . import gitutils
|
||||||
|
|
||||||
|
rootLogger = logging.getLogger()
|
||||||
|
rootLogger.setLevel(logging.DEBUG)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
fmt = logging.Formatter('{asctime} {levelname} {name}: {message}',
|
||||||
|
datefmt='%b %d %H:%M:%S',
|
||||||
|
style='{')
|
||||||
|
|
||||||
|
if not any(isinstance(h, logging.StreamHandler) for h in rootLogger.handlers):
|
||||||
|
hand = logging.StreamHandler()
|
||||||
|
hand.setFormatter(fmt)
|
||||||
|
rootLogger.addHandler(hand)
|
||||||
|
|
||||||
def reload():
|
def reload():
|
||||||
import importlib
|
import importlib
|
||||||
|
@ -15,3 +34,6 @@ def reload():
|
||||||
importlib.reload(keys)
|
importlib.reload(keys)
|
||||||
importlib.reload(version)
|
importlib.reload(version)
|
||||||
importlib.reload(cli)
|
importlib.reload(cli)
|
||||||
|
importlib.reload(docker)
|
||||||
|
importlib.reload(docker_assemble)
|
||||||
|
importlib.reload(gitutils)
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
from . import create_release
|
from . import create_release
|
||||||
|
from . import docker
|
||||||
|
from .environment import RelengEnvironment
|
||||||
|
from . import environment
|
||||||
|
import functools
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
@ -18,13 +22,16 @@ def do_tag(args):
|
||||||
no_check_git=args.no_check_git)
|
no_check_git=args.no_check_git)
|
||||||
|
|
||||||
|
|
||||||
def do_upload(args):
|
def do_upload(env: RelengEnvironment, args):
|
||||||
create_release.setup_creds()
|
create_release.setup_creds(env)
|
||||||
if args.target == 'all':
|
if args.target == 'all':
|
||||||
create_release.upload_artifacts(force_push_tag=args.force_push_tag,
|
docker.check_all_logins(env)
|
||||||
noconfirm=args.noconfirm)
|
create_release.upload_artifacts(env,
|
||||||
|
force_push_tag=args.force_push_tag,
|
||||||
|
noconfirm=args.noconfirm,
|
||||||
|
no_check_git=args.no_check_git)
|
||||||
elif args.target == 'manual':
|
elif args.target == 'manual':
|
||||||
create_release.upload_manual()
|
create_release.upload_manual(env)
|
||||||
else:
|
else:
|
||||||
raise ValueError('invalid target, unreachable')
|
raise ValueError('invalid target, unreachable')
|
||||||
|
|
||||||
|
@ -77,6 +84,10 @@ def main():
|
||||||
|
|
||||||
upload = sps.add_parser(
|
upload = sps.add_parser(
|
||||||
'upload', help='Upload artifacts to cache and releases bucket')
|
'upload', help='Upload artifacts to cache and releases bucket')
|
||||||
|
upload.add_argument(
|
||||||
|
'--no-check-git',
|
||||||
|
action='store_true',
|
||||||
|
help="Don't check git state before uploading. For testing.")
|
||||||
upload.add_argument('--force-push-tag',
|
upload.add_argument('--force-push-tag',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='Force push the tag. For testing.')
|
help='Force push the tag. For testing.')
|
||||||
|
@ -90,7 +101,12 @@ def main():
|
||||||
'--noconfirm',
|
'--noconfirm',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help="Don't ask for confirmation. For testing/automation.")
|
help="Don't ask for confirmation. For testing/automation.")
|
||||||
upload.set_defaults(cmd=do_upload)
|
upload.add_argument('--environment',
|
||||||
|
choices=list(environment.ENVIRONMENTS.keys()),
|
||||||
|
default='staging',
|
||||||
|
help='Environment to release to')
|
||||||
|
upload.set_defaults(cmd=lambda args: do_upload(
|
||||||
|
environment.ENVIRONMENTS[args.environment], args))
|
||||||
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
args.cmd(args)
|
args.cmd(args)
|
||||||
|
|
|
@ -7,19 +7,15 @@ import tempfile
|
||||||
import hashlib
|
import hashlib
|
||||||
import datetime
|
import datetime
|
||||||
from . import environment
|
from . import environment
|
||||||
|
from .environment import RelengEnvironment
|
||||||
from . import keys
|
from . import keys
|
||||||
|
from . import docker
|
||||||
from .version import VERSION, RELEASE_NAME, MAJOR
|
from .version import VERSION, RELEASE_NAME, MAJOR
|
||||||
|
from .gitutils import verify_are_on_tag, git_preconditions
|
||||||
|
|
||||||
$RAISE_SUBPROC_ERROR = True
|
$RAISE_SUBPROC_ERROR = True
|
||||||
$XONSH_SHOW_TRACEBACK = True
|
$XONSH_SHOW_TRACEBACK = True
|
||||||
|
|
||||||
RELENG_ENV = environment.STAGING
|
|
||||||
|
|
||||||
RELEASES_BUCKET = RELENG_ENV.releases_bucket
|
|
||||||
DOCS_BUCKET = RELENG_ENV.docs_bucket
|
|
||||||
CACHE_STORE = RELENG_ENV.cache_store_uri()
|
|
||||||
REPO = RELENG_ENV.git_repo
|
|
||||||
|
|
||||||
GCROOTS_DIR = Path('./release/gcroots')
|
GCROOTS_DIR = Path('./release/gcroots')
|
||||||
BUILT_GCROOTS_DIR = Path('./release/gcroots-build')
|
BUILT_GCROOTS_DIR = Path('./release/gcroots-build')
|
||||||
DRVS_TXT = Path('./release/drvs.txt')
|
DRVS_TXT = Path('./release/drvs.txt')
|
||||||
|
@ -35,23 +31,14 @@ MAX_JOBS = 2
|
||||||
RELEASE_SYSTEMS = ["x86_64-linux"]
|
RELEASE_SYSTEMS = ["x86_64-linux"]
|
||||||
|
|
||||||
|
|
||||||
def setup_creds():
|
def setup_creds(env: RelengEnvironment):
|
||||||
key = keys.get_ephemeral_key(RELENG_ENV)
|
key = keys.get_ephemeral_key(env)
|
||||||
$AWS_SECRET_ACCESS_KEY = key.secret_key
|
$AWS_SECRET_ACCESS_KEY = key.secret_key
|
||||||
$AWS_ACCESS_KEY_ID = key.id
|
$AWS_ACCESS_KEY_ID = key.id
|
||||||
$AWS_DEFAULT_REGION = 'garage'
|
$AWS_DEFAULT_REGION = 'garage'
|
||||||
$AWS_ENDPOINT_URL = environment.S3_ENDPOINT
|
$AWS_ENDPOINT_URL = environment.S3_ENDPOINT
|
||||||
|
|
||||||
|
|
||||||
def git_preconditions():
|
|
||||||
# verify there is nothing in index ready to stage
|
|
||||||
proc = !(git diff-index --quiet --cached HEAD --)
|
|
||||||
assert proc.rtn == 0
|
|
||||||
# verify there is nothing *stageable* and tracked
|
|
||||||
proc = !(git diff-files --quiet)
|
|
||||||
assert proc.rtn == 0
|
|
||||||
|
|
||||||
|
|
||||||
def official_release_commit_tag(force_tag=False):
|
def official_release_commit_tag(force_tag=False):
|
||||||
print('[+] Setting officialRelease in flake.nix and tagging')
|
print('[+] Setting officialRelease in flake.nix and tagging')
|
||||||
prev_branch = $(git symbolic-ref --short HEAD).strip()
|
prev_branch = $(git symbolic-ref --short HEAD).strip()
|
||||||
|
@ -102,13 +89,13 @@ def eval_jobs():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def upload_drv_paths_and_outputs(paths: list[str]):
|
def upload_drv_paths_and_outputs(env: RelengEnvironment, paths: list[str]):
|
||||||
proc = subprocess.Popen([
|
proc = subprocess.Popen([
|
||||||
'nix',
|
'nix',
|
||||||
'copy',
|
'copy',
|
||||||
'-v',
|
'-v',
|
||||||
'--to',
|
'--to',
|
||||||
CACHE_STORE,
|
env.cache_store_uri(),
|
||||||
'--stdin',
|
'--stdin',
|
||||||
],
|
],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
|
@ -245,33 +232,38 @@ def prepare_release_notes():
|
||||||
git commit -m @(commit_msg)
|
git commit -m @(commit_msg)
|
||||||
|
|
||||||
|
|
||||||
def verify_are_on_tag():
|
def upload_artifacts(env: RelengEnvironment, noconfirm=False, no_check_git=False, force_push_tag=False):
|
||||||
current_tag = $(git describe --tag).strip()
|
if not no_check_git:
|
||||||
assert current_tag == VERSION
|
verify_are_on_tag()
|
||||||
|
git_preconditions()
|
||||||
|
|
||||||
def upload_artifacts(noconfirm=False, force_push_tag=False):
|
|
||||||
assert 'AWS_SECRET_ACCESS_KEY' in __xonsh__.env
|
assert 'AWS_SECRET_ACCESS_KEY' in __xonsh__.env
|
||||||
|
|
||||||
tree @(ARTIFACTS)
|
tree @(ARTIFACTS)
|
||||||
|
|
||||||
|
env_part = f'environment {env.name}'
|
||||||
not noconfirm and confirm(
|
not noconfirm and confirm(
|
||||||
f'Would you like to release {ARTIFACTS} as {VERSION}? Type "I want to release this" to confirm\n',
|
f'Would you like to release {ARTIFACTS} as {VERSION} in {env.colour(env_part)}? Type "I want to release this to {env.name}" to confirm\n',
|
||||||
'I want to release this'
|
f'I want to release this to {env.name}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
docker_images = list((ARTIFACTS / f'lix/lix-{VERSION}').glob(f'lix-{VERSION}-docker-image-*.tar.gz'))
|
||||||
|
assert docker_images
|
||||||
|
|
||||||
print('[+] Upload to cache')
|
print('[+] Upload to cache')
|
||||||
with open(DRVS_TXT) as fh:
|
with open(DRVS_TXT) as fh:
|
||||||
upload_drv_paths_and_outputs([x.strip() for x in fh.readlines() if x])
|
upload_drv_paths_and_outputs(env, [x.strip() for x in fh.readlines() if x])
|
||||||
|
|
||||||
|
print('[+] Upload docker images')
|
||||||
|
for target in env.docker_targets:
|
||||||
|
docker.upload_docker_images(target, docker_images)
|
||||||
|
|
||||||
print('[+] Upload to release bucket')
|
print('[+] Upload to release bucket')
|
||||||
aws s3 cp --recursive @(ARTIFACTS)/ @(RELEASES_BUCKET)/
|
aws s3 cp --recursive @(ARTIFACTS)/ @(env.releases_bucket)/
|
||||||
print('[+] Upload manual')
|
print('[+] Upload manual')
|
||||||
upload_manual()
|
upload_manual(env)
|
||||||
|
|
||||||
print('[+] git push tag')
|
print('[+] git push tag')
|
||||||
git push @(['-f'] if force_push_tag else []) @(REPO) f'{VERSION}:refs/tags/{VERSION}'
|
git push @(['-f'] if force_push_tag else []) @(env.git_repo) f'{VERSION}:refs/tags/{VERSION}'
|
||||||
|
|
||||||
|
|
||||||
def do_tag_merge(force_tag=False, no_check_git=False):
|
def do_tag_merge(force_tag=False, no_check_git=False):
|
||||||
|
@ -290,7 +282,7 @@ def build_manual(eval_result):
|
||||||
cp --no-preserve=mode -vr @(manual)/share/doc/nix @(MANUAL)
|
cp --no-preserve=mode -vr @(manual)/share/doc/nix @(MANUAL)
|
||||||
|
|
||||||
|
|
||||||
def upload_manual():
|
def upload_manual(env: RelengEnvironment):
|
||||||
stable = json.loads($(nix eval --json '.#nix.officialRelease'))
|
stable = json.loads($(nix eval --json '.#nix.officialRelease'))
|
||||||
if stable:
|
if stable:
|
||||||
version = MAJOR
|
version = MAJOR
|
||||||
|
@ -298,9 +290,9 @@ def upload_manual():
|
||||||
version = 'nightly'
|
version = 'nightly'
|
||||||
|
|
||||||
print('[+] aws s3 sync manual')
|
print('[+] aws s3 sync manual')
|
||||||
aws s3 sync @(MANUAL)/ @(DOCS_BUCKET)/manual/lix/@(version)/
|
aws s3 sync @(MANUAL)/ @(env.docs_bucket)/manual/lix/@(version)/
|
||||||
if stable:
|
if stable:
|
||||||
aws s3 sync @(MANUAL)/ @(DOCS_BUCKET)/manual/lix/stable/
|
aws s3 sync @(MANUAL)/ @(env.docs_bucket)/manual/lix/stable/
|
||||||
|
|
||||||
|
|
||||||
def build_artifacts(no_check_git=False):
|
def build_artifacts(no_check_git=False):
|
||||||
|
@ -318,7 +310,8 @@ def build_artifacts(no_check_git=False):
|
||||||
build_manual(eval_result)
|
build_manual(eval_result)
|
||||||
|
|
||||||
with open(DRVS_TXT, 'w') as fh:
|
with open(DRVS_TXT, 'w') as fh:
|
||||||
fh.write('\n'.join(drv_paths))
|
# don't bother putting the release tarballs themselves because they are duplicate and huge
|
||||||
|
fh.write('\n'.join(x['drvPath'] for x in eval_result if x['attr'] != 'lix-release-tarballs'))
|
||||||
|
|
||||||
make_artifacts_dir(eval_result, ARTIFACTS)
|
make_artifacts_dir(eval_result, ARTIFACTS)
|
||||||
print(f'[+] Done! See {ARTIFACTS}')
|
print(f'[+] Done! See {ARTIFACTS}')
|
||||||
|
|
74
releng/docker.xsh
Normal file
74
releng/docker.xsh
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .environment import DockerTarget, RelengEnvironment
|
||||||
|
from .version import VERSION, MAJOR
|
||||||
|
from . import gitutils
|
||||||
|
from .docker_assemble import Registry, OCIIndex, OCIIndexItem
|
||||||
|
from . import docker_assemble
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
def check_all_logins(env: RelengEnvironment):
|
||||||
|
for target in env.docker_targets:
|
||||||
|
check_login(target)
|
||||||
|
|
||||||
|
def check_login(target: DockerTarget):
|
||||||
|
skopeo login @(target.registry_name())
|
||||||
|
|
||||||
|
def upload_docker_images(target: DockerTarget, paths: list[Path]):
|
||||||
|
if not paths: return
|
||||||
|
|
||||||
|
sess = requests.Session()
|
||||||
|
sess.headers['User-Agent'] = 'lix-releng'
|
||||||
|
|
||||||
|
tag_names = [DockerTarget.resolve(tag, version=VERSION, major=MAJOR) for tag in target.tags]
|
||||||
|
|
||||||
|
# latest only gets tagged for the current release branch of Lix
|
||||||
|
if not gitutils.is_maintenance_branch('HEAD'):
|
||||||
|
tag_names.append('latest')
|
||||||
|
|
||||||
|
meta = {}
|
||||||
|
|
||||||
|
reg = docker_assemble.Registry(sess)
|
||||||
|
manifests = []
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
digest_file = tmp / (path.name + '.digest')
|
||||||
|
inspection = json.loads($(skopeo inspect docker-archive:@(path)))
|
||||||
|
|
||||||
|
docker_arch = inspection['Architecture']
|
||||||
|
docker_os = inspection['Os']
|
||||||
|
meta = inspection['Labels']
|
||||||
|
|
||||||
|
log.info('Pushing image %s for %s to %s', path, docker_arch, target.registry_path)
|
||||||
|
|
||||||
|
# insecure-policy: we don't have any signature policy, we are just uploading an image
|
||||||
|
# We upload to a junk tag, because otherwise it will upload to `latest`, which is undesirable
|
||||||
|
skopeo --insecure-policy copy --format oci --digestfile @(digest_file) docker-archive:@(path) docker://@(target.registry_path):temp
|
||||||
|
|
||||||
|
digest = digest_file.read_text().strip()
|
||||||
|
|
||||||
|
# skopeo doesn't give us the manifest size directly, so we just ask the registry
|
||||||
|
metadata = reg.image_info(target.registry_path, digest)
|
||||||
|
|
||||||
|
manifests.append(OCIIndexItem(metadata=metadata, architecture=docker_arch, os=docker_os))
|
||||||
|
# delete the temp tag, which we only have to create because of skopeo
|
||||||
|
# limitations anyhow (it seems to not have a way to say "don't tag it, find
|
||||||
|
# your checksum and put it there")
|
||||||
|
# FIXME: this is not possible because GitHub only has a proprietary API for it. amazing. 11/10.
|
||||||
|
# reg.delete_tag(target.registry_path, 'temp')
|
||||||
|
|
||||||
|
log.info('Pushed images to %r, building a bigger and more menacing manifest from %r with metadata %r', target, manifests, meta)
|
||||||
|
# send the multiarch manifest to each tag
|
||||||
|
index = OCIIndex(manifests=manifests, annotations=meta)
|
||||||
|
for tag in tag_names:
|
||||||
|
reg.upload_index(target.registry_path, tag, index)
|
399
releng/docker_assemble.py
Normal file
399
releng/docker_assemble.py
Normal file
|
@ -0,0 +1,399 @@
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import dataclasses
|
||||||
|
import time
|
||||||
|
from urllib.parse import unquote
|
||||||
|
import urllib.request
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests.auth
|
||||||
|
import requests
|
||||||
|
import xdg_base_dirs
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
DEBUG_REQUESTS = False
|
||||||
|
if DEBUG_REQUESTS:
|
||||||
|
urllib3_logger = logging.getLogger('requests.packages.urllib3')
|
||||||
|
urllib3_logger.setLevel(logging.DEBUG)
|
||||||
|
urllib3_logger.propagate = True
|
||||||
|
|
||||||
|
# So, there is a bunch of confusing stuff happening in this file. The gist of why it's Like This is:
|
||||||
|
#
|
||||||
|
# nix2container does not concern itself with tags (reasonably enough):
|
||||||
|
# https://github.com/nlewo/nix2container/issues/59
|
||||||
|
#
|
||||||
|
# This is fine. But then we noticed: docker images don't play nice if you have
|
||||||
|
# multiple architectures you want to abstract over if you don't do special
|
||||||
|
# things. Those special things are images with manifests containing multiple
|
||||||
|
# images.
|
||||||
|
#
|
||||||
|
# Docker has a data model vaguely analogous to git: you have higher level
|
||||||
|
# objects referring to a bunch of content-addressed blobs.
|
||||||
|
#
|
||||||
|
# A multiarch image is more or less just a manifest that refers to more
|
||||||
|
# manifests; in OCI it is an Index.
|
||||||
|
#
|
||||||
|
# See the API spec here: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#definitions
|
||||||
|
# And the Index spec here: https://github.com/opencontainers/image-spec/blob/v1.0.1/image-index.md
|
||||||
|
#
|
||||||
|
# skopeo doesn't *know* how to make multiarch *manifests*:
|
||||||
|
# https://github.com/containers/skopeo/issues/1136
|
||||||
|
#
|
||||||
|
# There is a tool called manifest-tool that is supposed to do this
|
||||||
|
# (https://github.com/estesp/manifest-tool) but it doesn't support putting in
|
||||||
|
# annotations on the outer image, and I *really* didn't want to write golang to
|
||||||
|
# fix that. Thus, a little bit of homebrew containers code.
|
||||||
|
#
|
||||||
|
# Essentially what we are doing in here is splatting a bunch of images into the
|
||||||
|
# registry without tagging them (except as "temp", due to podman issues), then
|
||||||
|
# simply sending a new composite manifest ourselves.
|
||||||
|
|
||||||
|
DockerArchitecture = Literal['amd64'] | Literal['arm64']
|
||||||
|
MANIFEST_MIME = 'application/vnd.oci.image.manifest.v1+json'
|
||||||
|
INDEX_MIME = 'application/vnd.oci.image.index.v1+json'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, order=True)
|
||||||
|
class ImageMetadata:
|
||||||
|
size: int
|
||||||
|
digest: str
|
||||||
|
"""sha256:SOMEHEX"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, order=True)
|
||||||
|
class OCIIndexItem:
|
||||||
|
"""Information about an untagged uploaded image."""
|
||||||
|
|
||||||
|
metadata: ImageMetadata
|
||||||
|
|
||||||
|
architecture: DockerArchitecture
|
||||||
|
|
||||||
|
os: str = 'linux'
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
return {
|
||||||
|
'mediaType': MANIFEST_MIME,
|
||||||
|
'size': self.metadata.size,
|
||||||
|
'digest': self.metadata.digest,
|
||||||
|
'platform': {
|
||||||
|
'architecture': self.architecture,
|
||||||
|
'os': self.os,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class OCIIndex:
|
||||||
|
manifests: list[OCIIndexItem]
|
||||||
|
|
||||||
|
annotations: dict[str, str]
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
return {
|
||||||
|
'schemaVersion': 2,
|
||||||
|
'manifests': [item.serialize() for item in sorted(self.manifests)],
|
||||||
|
'annotations': self.annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def docker_architecture_from_nix_system(system: str) -> DockerArchitecture:
|
||||||
|
MAP = {
|
||||||
|
'x86_64-linux': 'amd64',
|
||||||
|
'aarch64-linux': 'arm64',
|
||||||
|
}
|
||||||
|
return MAP[system] # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class TaggingOperation:
|
||||||
|
manifest: OCIIndex
|
||||||
|
tags: list[str]
|
||||||
|
"""Tags this image is uploaded under"""
|
||||||
|
|
||||||
|
|
||||||
|
runtime_dir = xdg_base_dirs.xdg_runtime_dir()
|
||||||
|
config_dir = xdg_base_dirs.xdg_config_home()
|
||||||
|
|
||||||
|
AUTH_FILES = ([runtime_dir / 'containers/auth.json'] if runtime_dir else []) + \
|
||||||
|
[config_dir / 'containers/auth.json', Path.home() / '.docker/config.json']
|
||||||
|
|
||||||
|
|
||||||
|
# Copied from Werkzeug https://github.com/pallets/werkzeug/blob/62e3ea45846d06576199a2f8470be7fe44c867c1/src/werkzeug/http.py#L300-L325
|
||||||
|
def parse_list_header(value: str) -> list[str]:
|
||||||
|
"""Parse a header value that consists of a list of comma separated items according
|
||||||
|
to `RFC 9110 <https://httpwg.org/specs/rfc9110.html#abnf.extension>`__.
|
||||||
|
|
||||||
|
This extends :func:`urllib.request.parse_http_list` to remove surrounding quotes
|
||||||
|
from values.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
parse_list_header('token, "quoted value"')
|
||||||
|
['token', 'quoted value']
|
||||||
|
|
||||||
|
This is the reverse of :func:`dump_header`.
|
||||||
|
|
||||||
|
:param value: The header value to parse.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for item in urllib.request.parse_http_list(value):
|
||||||
|
if len(item) >= 2 and item[0] == item[-1] == '"':
|
||||||
|
item = item[1:-1]
|
||||||
|
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# https://www.rfc-editor.org/rfc/rfc2231#section-4
|
||||||
|
_charset_value_re = re.compile(
|
||||||
|
r"""
|
||||||
|
([\w!#$%&*+\-.^`|~]*)' # charset part, could be empty
|
||||||
|
[\w!#$%&*+\-.^`|~]*' # don't care about language part, usually empty
|
||||||
|
([\w!#$%&'*+\-.^`|~]+) # one or more token chars with percent encoding
|
||||||
|
""",
|
||||||
|
re.ASCII | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Copied from: https://github.com/pallets/werkzeug/blob/62e3ea45846d06576199a2f8470be7fe44c867c1/src/werkzeug/http.py#L327-L394
|
||||||
|
def parse_dict_header(value: str) -> dict[str, str | None]:
|
||||||
|
"""Parse a list header using :func:`parse_list_header`, then parse each item as a
|
||||||
|
``key=value`` pair.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
parse_dict_header('a=b, c="d, e", f')
|
||||||
|
{"a": "b", "c": "d, e", "f": None}
|
||||||
|
|
||||||
|
This is the reverse of :func:`dump_header`.
|
||||||
|
|
||||||
|
If a key does not have a value, it is ``None``.
|
||||||
|
|
||||||
|
This handles charsets for values as described in
|
||||||
|
`RFC 2231 <https://www.rfc-editor.org/rfc/rfc2231#section-3>`__. Only ASCII, UTF-8,
|
||||||
|
and ISO-8859-1 charsets are accepted, otherwise the value remains quoted.
|
||||||
|
|
||||||
|
:param value: The header value to parse.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.0
|
||||||
|
Passing bytes is not supported.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.0
|
||||||
|
The ``cls`` argument is removed.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.3
|
||||||
|
Added support for ``key*=charset''value`` encoded items.
|
||||||
|
|
||||||
|
.. versionchanged:: 0.9
|
||||||
|
The ``cls`` argument was added.
|
||||||
|
"""
|
||||||
|
result: dict[str, str | None] = {}
|
||||||
|
|
||||||
|
for item in parse_list_header(value):
|
||||||
|
key, has_value, value = item.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
|
||||||
|
if not has_value:
|
||||||
|
result[key] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
encoding: str | None = None
|
||||||
|
|
||||||
|
if key[-1] == "*":
|
||||||
|
# key*=charset''value becomes key=value, where value is percent encoded
|
||||||
|
# adapted from parse_options_header, without the continuation handling
|
||||||
|
key = key[:-1]
|
||||||
|
match = _charset_value_re.match(value)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# If there is a charset marker in the value, split it off.
|
||||||
|
encoding, value = match.groups()
|
||||||
|
assert encoding
|
||||||
|
encoding = encoding.lower()
|
||||||
|
|
||||||
|
# A safe list of encodings. Modern clients should only send ASCII or UTF-8.
|
||||||
|
# This list will not be extended further. An invalid encoding will leave the
|
||||||
|
# value quoted.
|
||||||
|
if encoding in {"ascii", "us-ascii", "utf-8", "iso-8859-1"}:
|
||||||
|
# invalid bytes are replaced during unquoting
|
||||||
|
value = unquote(value, encoding=encoding)
|
||||||
|
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
result[key] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_www_authenticate(www_authenticate):
|
||||||
|
scheme, _, rest = www_authenticate.partition(' ')
|
||||||
|
scheme = scheme.lower()
|
||||||
|
rest = rest.strip()
|
||||||
|
|
||||||
|
parsed = parse_dict_header(rest.rstrip('='))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class AuthState:
|
||||||
|
|
||||||
|
def __init__(self, auth_files: list[Path] = AUTH_FILES):
|
||||||
|
self.auth_map: dict[str, str] = {}
|
||||||
|
for f in auth_files:
|
||||||
|
self.auth_map.update(AuthState.load_auth_file(f))
|
||||||
|
self.token_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_auth_file(path: Path) -> dict[str, str]:
|
||||||
|
if path.exists():
|
||||||
|
with path.open() as fh:
|
||||||
|
try:
|
||||||
|
json_obj = json.load(fh)
|
||||||
|
return {k: v['auth'] for k, v in json_obj['auths'].items()}
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
log.exception('JSON decode error in %s', path, exc_info=e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_token(self, hostname: str) -> Optional[str]:
|
||||||
|
return self.token_cache.get(hostname)
|
||||||
|
|
||||||
|
def obtain_token(self, session: requests.Session, token_endpoint: str,
|
||||||
|
scope: str, service: str, image_path: str) -> str:
|
||||||
|
authority, _, _ = image_path.partition('/')
|
||||||
|
if tok := self.get_token(authority):
|
||||||
|
return tok
|
||||||
|
|
||||||
|
creds = self.find_credential_for(image_path)
|
||||||
|
if not creds:
|
||||||
|
raise ValueError('No credentials available for ' + image_path)
|
||||||
|
|
||||||
|
resp = session.get(token_endpoint,
|
||||||
|
params={
|
||||||
|
'client_id': 'lix-releng',
|
||||||
|
'scope': scope,
|
||||||
|
'service': service,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Basic ' + creds
|
||||||
|
}).json()
|
||||||
|
token = resp['token']
|
||||||
|
self.token_cache[service] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
def find_credential_for(self, image_path: str):
|
||||||
|
trails = image_path.split('/')
|
||||||
|
for i in range(len(trails)):
|
||||||
|
prefix = '/'.join(trails[:len(trails) - i])
|
||||||
|
if prefix in self.auth_map:
|
||||||
|
return self.auth_map[prefix]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryAuthenticator(requests.auth.AuthBase):
|
||||||
|
"""Authenticates to an OCI compliant registry"""
|
||||||
|
|
||||||
|
def __init__(self, auth_state: AuthState, session: requests.Session,
|
||||||
|
image: str):
|
||||||
|
self.auth_map: dict[str, str] = {}
|
||||||
|
self.image = image
|
||||||
|
self.session = session
|
||||||
|
self.auth_state = auth_state
|
||||||
|
|
||||||
|
def response_hook(self, r: requests.Response,
|
||||||
|
**kwargs: Any) -> requests.Response:
|
||||||
|
if r.status_code == 401:
|
||||||
|
www_authenticate = r.headers.get('www-authenticate', '').lower()
|
||||||
|
parsed = parse_www_authenticate(www_authenticate)
|
||||||
|
assert parsed
|
||||||
|
|
||||||
|
tok = self.auth_state.obtain_token(
|
||||||
|
self.session,
|
||||||
|
parsed['realm'], # type: ignore
|
||||||
|
parsed['scope'], # type: ignore
|
||||||
|
parsed['service'], # type: ignore
|
||||||
|
self.image)
|
||||||
|
|
||||||
|
new_req = r.request.copy()
|
||||||
|
new_req.headers['Authorization'] = 'Bearer ' + tok
|
||||||
|
|
||||||
|
return self.session.send(new_req)
|
||||||
|
else:
|
||||||
|
return r
|
||||||
|
|
||||||
|
def __call__(self,
|
||||||
|
r: requests.PreparedRequest) -> requests.PreparedRequest:
|
||||||
|
authority, _, _ = self.image.partition('/')
|
||||||
|
auth_may = self.auth_state.get_token(authority)
|
||||||
|
|
||||||
|
if auth_may:
|
||||||
|
r.headers['Authorization'] = 'Bearer ' + auth_may
|
||||||
|
|
||||||
|
r.register_hook('response', self.response_hook)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
|
||||||
|
def __init__(self, session: requests.Session):
|
||||||
|
self.auth_state = AuthState()
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def image_info(self, image_path: str, manifest_id: str) -> ImageMetadata:
|
||||||
|
authority, _, path = image_path.partition('/')
|
||||||
|
resp = self.session.head(
|
||||||
|
f'https://{authority}/v2/{path}/manifests/{manifest_id}',
|
||||||
|
headers={'Accept': MANIFEST_MIME},
|
||||||
|
auth=RegistryAuthenticator(self.auth_state, self.session,
|
||||||
|
image_path))
|
||||||
|
resp.raise_for_status()
|
||||||
|
return ImageMetadata(int(resp.headers['content-length']),
|
||||||
|
resp.headers['docker-content-digest'])
|
||||||
|
|
||||||
|
def delete_tag(self, image_path: str, tag: str):
|
||||||
|
authority, _, path = image_path.partition('/')
|
||||||
|
resp = self.session.delete(
|
||||||
|
f'https://{authority}/v2/{path}/manifests/{tag}',
|
||||||
|
headers={'Content-Type': INDEX_MIME},
|
||||||
|
auth=RegistryAuthenticator(self.auth_state, self.session,
|
||||||
|
image_path))
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
def _upload_index(self, image_path: str, tag: str, index: OCIIndex):
|
||||||
|
authority, _, path = image_path.partition('/')
|
||||||
|
body = json.dumps(index.serialize(),
|
||||||
|
separators=(',', ':'),
|
||||||
|
sort_keys=True)
|
||||||
|
|
||||||
|
resp = self.session.put(
|
||||||
|
f'https://{authority}/v2/{path}/manifests/{tag}',
|
||||||
|
data=body,
|
||||||
|
headers={'Content-Type': INDEX_MIME},
|
||||||
|
auth=RegistryAuthenticator(self.auth_state, self.session,
|
||||||
|
image_path))
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return resp.headers['Location']
|
||||||
|
|
||||||
|
def upload_index(self,
|
||||||
|
image_path: str,
|
||||||
|
tag: str,
|
||||||
|
index: OCIIndex,
|
||||||
|
retries=20,
|
||||||
|
retry_delay=1):
|
||||||
|
# eventual consistency lmao
|
||||||
|
for _ in range(retries):
|
||||||
|
try:
|
||||||
|
return self._upload_index(image_path, tag, index)
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code != 404:
|
||||||
|
raise
|
||||||
|
|
||||||
|
time.sleep(retry_delay)
|
|
@ -1,5 +1,9 @@
|
||||||
import dataclasses
|
from typing import Callable
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import re
|
||||||
|
import functools
|
||||||
|
import subprocess
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
S3_HOST = 's3.lix.systems'
|
S3_HOST = 's3.lix.systems'
|
||||||
S3_ENDPOINT = 'https://s3.lix.systems'
|
S3_ENDPOINT = 'https://s3.lix.systems'
|
||||||
|
@ -16,9 +20,32 @@ DEFAULT_STORE_URI_BITS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class DockerTarget:
|
||||||
|
registry_path: str
|
||||||
|
"""Registry path without the tag, e.g. ghcr.io/lix-project/lix"""
|
||||||
|
|
||||||
|
tags: list[str]
|
||||||
|
"""List of tags this image should take. There must be at least one."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve(item: str, version: str, major: str) -> str:
|
||||||
|
"""
|
||||||
|
Applies templates:
|
||||||
|
- version: the Lix version e.g. 2.90.0
|
||||||
|
- major: the major Lix version e.g. 2.90
|
||||||
|
"""
|
||||||
|
return item.format(version=version, major=major)
|
||||||
|
|
||||||
|
def registry_name(self) -> str:
|
||||||
|
[a, _, _] = self.registry_path.partition('/')
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class RelengEnvironment:
|
class RelengEnvironment:
|
||||||
name: str
|
name: str
|
||||||
|
colour: Callable[[str], str]
|
||||||
|
|
||||||
cache_store_overlay: dict[str, str]
|
cache_store_overlay: dict[str, str]
|
||||||
cache_bucket: str
|
cache_bucket: str
|
||||||
|
@ -26,22 +53,79 @@ class RelengEnvironment:
|
||||||
docs_bucket: str
|
docs_bucket: str
|
||||||
git_repo: str
|
git_repo: str
|
||||||
|
|
||||||
|
docker_targets: list[DockerTarget]
|
||||||
|
|
||||||
def cache_store_uri(self):
|
def cache_store_uri(self):
|
||||||
qs = DEFAULT_STORE_URI_BITS.copy()
|
qs = DEFAULT_STORE_URI_BITS.copy()
|
||||||
qs.update(self.cache_store_overlay)
|
qs.update(self.cache_store_overlay)
|
||||||
return self.cache_bucket + "?" + urllib.parse.urlencode(qs)
|
return self.cache_bucket + "?" + urllib.parse.urlencode(qs)
|
||||||
|
|
||||||
|
|
||||||
|
SGR = '\x1b['
|
||||||
|
RED = '31;1m'
|
||||||
|
GREEN = '32;1m'
|
||||||
|
RESET = '0m'
|
||||||
|
|
||||||
|
|
||||||
|
def sgr(colour: str, text: str) -> str:
|
||||||
|
return f'{SGR}{colour}{text}{SGR}{RESET}'
|
||||||
|
|
||||||
|
|
||||||
STAGING = RelengEnvironment(
|
STAGING = RelengEnvironment(
|
||||||
name='staging',
|
name='staging',
|
||||||
|
colour=functools.partial(sgr, GREEN),
|
||||||
docs_bucket='s3://staging-docs',
|
docs_bucket='s3://staging-docs',
|
||||||
cache_bucket='s3://staging-cache',
|
cache_bucket='s3://staging-cache',
|
||||||
cache_store_overlay={
|
cache_store_overlay={'secret-key': 'staging.key'},
|
||||||
'secret-key': 'staging.key'
|
|
||||||
},
|
|
||||||
releases_bucket='s3://staging-releases',
|
releases_bucket='s3://staging-releases',
|
||||||
git_repo='ssh://git@git.lix.systems/lix-project/lix-releng-staging',
|
git_repo='ssh://git@git.lix.systems/lix-project/lix-releng-staging',
|
||||||
|
docker_targets=[
|
||||||
|
# latest will be auto tagged if appropriate
|
||||||
|
DockerTarget('git.lix.systems/lix-project/lix-releng-staging',
|
||||||
|
tags=['{version}', '{major}']),
|
||||||
|
DockerTarget('ghcr.io/lix-project/lix-releng-staging',
|
||||||
|
tags=['{version}', '{major}']),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GERRIT_REMOTE_RE = re.compile(r'^ssh://(\w+@)?gerrit.lix.systems:2022/lix$')
|
||||||
|
|
||||||
|
|
||||||
|
def guess_gerrit_remote():
|
||||||
|
"""
|
||||||
|
Deals with people having unknown gerrit username.
|
||||||
|
"""
|
||||||
|
out = [
|
||||||
|
x.split()[1] for x in subprocess.check_output(
|
||||||
|
['git', 'remote', '-v']).decode().splitlines()
|
||||||
|
]
|
||||||
|
return next(x for x in out if GERRIT_REMOTE_RE.match(x))
|
||||||
|
|
||||||
|
|
||||||
|
PROD = RelengEnvironment(
|
||||||
|
name='production',
|
||||||
|
colour=functools.partial(sgr, RED),
|
||||||
|
docs_bucket='s3://docs',
|
||||||
|
cache_bucket='s3://cache',
|
||||||
|
# FIXME: we should decrypt this with age into a tempdir in the future, but
|
||||||
|
# the issue is how to deal with the recipients file. For now, we should
|
||||||
|
# just delete it after doing a release.
|
||||||
|
cache_store_overlay={'secret-key': 'prod.key'},
|
||||||
|
releases_bucket='s3://releases',
|
||||||
|
git_repo=guess_gerrit_remote(),
|
||||||
|
docker_targets=[
|
||||||
|
# latest will be auto tagged if appropriate
|
||||||
|
DockerTarget('git.lix.systems/lix-project/lix',
|
||||||
|
tags=['{version}', '{major}']),
|
||||||
|
DockerTarget('ghcr.io/lix-project/lix', tags=['{version}', '{major}']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
ENVIRONMENTS = {
|
||||||
|
'staging': STAGING,
|
||||||
|
'production': PROD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class S3Credentials:
|
class S3Credentials:
|
||||||
|
|
37
releng/gitutils.xsh
Normal file
37
releng/gitutils.xsh
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def version_compare(v1: str, v2: str):
|
||||||
|
return json.loads($(nix-instantiate --eval --json --argstr v1 @(v1) --argstr v2 @(v2) --expr '{v1, v2}: builtins.compareVersions v1 v2'))
|
||||||
|
|
||||||
|
|
||||||
|
def latest_tag_on_branch(branch: str) -> str:
|
||||||
|
return $(git describe --abbrev=0 @(branch) e>/dev/null).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_maintenance_branch(branch: str) -> bool:
|
||||||
|
try:
|
||||||
|
main_tag = latest_tag_on_branch('main')
|
||||||
|
current_tag = latest_tag_on_branch(branch)
|
||||||
|
|
||||||
|
return version_compare(current_tag, main_tag) < 0
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# This is the case before Lix releases 2.90, since main *has* no
|
||||||
|
# release tag on it.
|
||||||
|
# FIXME: delete this case after 2.91
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_are_on_tag():
|
||||||
|
current_tag = $(git describe --tag).strip()
|
||||||
|
assert current_tag == VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def git_preconditions():
|
||||||
|
# verify there is nothing in index ready to stage
|
||||||
|
proc = !(git diff-index --quiet --cached HEAD --)
|
||||||
|
assert proc.rtn == 0
|
||||||
|
# verify there is nothing *stageable* and tracked
|
||||||
|
proc = !(git diff-files --quiet)
|
||||||
|
assert proc.rtn == 0
|
|
@ -33,7 +33,7 @@ let
|
||||||
targetName = "*.tar.xz";
|
targetName = "*.tar.xz";
|
||||||
}) systems
|
}) systems
|
||||||
++ builtins.map (system: {
|
++ builtins.map (system: {
|
||||||
target = hydraJobs.dockerImage.${system};
|
target = hydraJobs.dockerImage.${system}.tarball;
|
||||||
targetName = "image.tar.gz";
|
targetName = "image.tar.gz";
|
||||||
rename = "lix-${lix.version}-docker-image-${system}.tar.gz";
|
rename = "lix-${lix.version}-docker-image-${system}.tar.gz";
|
||||||
}) dockerSystems;
|
}) dockerSystems;
|
||||||
|
|
Loading…
Reference in a new issue