diff --git a/.github/workflows/build-publish.yaml b/.github/workflows/build-publish.yaml new file mode 100644 index 0000000..7f3e199 --- /dev/null +++ b/.github/workflows/build-publish.yaml @@ -0,0 +1,95 @@ +name: 'Build & Publish Container Image' + +on: + push: + pull_request: + +permissions: + contents: 'read' + +jobs: + test-scripts: + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' + uses: 'actions/checkout@v3' + - name: 'Test ๐Ÿงช' + run: 'ruby .github/workflows/scripts/test/get-image-tags.unit.rb' + + container-build: + needs: 'test-scripts' + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' + uses: 'actions/checkout@v3' + - uses: 'docker/setup-buildx-action@v2' + + - name: 'Get image tag names ๐Ÿท๏ธ' + id: 'tag-image' + run: | + echo "image_tags=$( + .github/workflows/scripts/get-image-tags.rb \ + "${{ github.repository_owner }}" \ + "${{ github.event.repository.name }}" \ + "${{ github.ref_name }}" \ + "${{ github.ref_type }}" \ + "${{ github.event.repository.default_branch }}" \ + )" >> "$GITHUB_OUTPUT" + + - name: 'Set up QEMU ๐Ÿฆ…' + id: 'qemu' + uses: 'docker/setup-qemu-action@v2' + with: + platforms: 'arm64' + - name: 'Set target build platforms ๐Ÿ“' + id: 'target-platforms' + run: | + qemu_platforms="$(.github/workflows/scripts/convert-arch-to-platform.sh "${{ steps.qemu.outputs.platforms }}")" + echo "target_platforms=$qemu_platforms" >> "$GITHUB_OUTPUT" + + - name: 'Build container ๐Ÿณ' + id: 'build' + uses: 'docker/build-push-action@v3' + with: + file: 'Dockerfile' + tags: '${{ steps.tag-image.outputs.image_tags }}' + platforms: '${{ steps.target-platforms.outputs.target_platforms }}' + cache-from: 'type=gha' + cache-to: 'type=gha,mode=max' + + outputs: + image_tags: '${{ steps.tag-image.outputs.image_tags }}' + qemu_platforms: '${{ steps.qemu.outputs.platforms }}' + docker_platforms: '${{ steps.target-platforms.outputs.target_platforms }}' + + publish: + needs: + - 'container-build' + if: contains(needs.container-build.outputs.image_tags, ':latest') + runs-on: 'ubuntu-latest' + permissions: + packages: 'write' + steps: + - name: 'Checkout Repository ๐Ÿ›Ž๏ธ' + uses: 'actions/checkout@v3' + - uses: 'docker/setup-buildx-action@v2' + - name: 'Login to GitHub Container Registry ๐Ÿ”‘' + uses: 'docker/login-action@v2' + with: + registry: 'ghcr.io' + username: '${{ github.repository_owner }}' + password: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Set up QEMU ๐Ÿฆ…' + uses: 'docker/setup-qemu-action@v2' + with: + platforms: '${{ needs.container-build.outputs.qemu_platforms }}' + + - name: 'Publish to Registry ๐Ÿ’จ' + uses: 'docker/build-push-action@v3' + with: + file: 'Dockerfile' + push: true + tags: '${{ needs.container-build.outputs.image_tags }}' + platforms: '${{ needs.container-build.outputs.docker_platforms }}' + cache-from: 'type=gha' diff --git a/.github/workflows/scripts/convert-arch-to-platform.sh b/.github/workflows/scripts/convert-arch-to-platform.sh new file mode 100755 index 0000000..1f7449e --- /dev/null +++ b/.github/workflows/scripts/convert-arch-to-platform.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +qemu_platforms="" + +for p in $(echo "$1" | tr ',' '\n'); do + v="" + case "$p" in + "linux/arm64") + v="$p/v8" ;; + # Skip platforms we don't need + "linux/386") ;; + *) + v="$p" ;; + esac + + if [ -z "$qemu_platforms" ]; then + qemu_platforms="$v" + else + qemu_platforms="$qemu_platforms,$v" + fi +done + +echo "$qemu_platforms" diff --git a/.github/workflows/scripts/get-image-tags.rb b/.github/workflows/scripts/get-image-tags.rb new file mode 100755 index 0000000..1d7454c --- /dev/null +++ b/.github/workflows/scripts/get-image-tags.rb @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby + +require 'json' +require_relative 'lib' + +def main + repo_owner = ARGV[0] + repo_name = ARGV[1] + git_ref_name = ARGV[2] + git_ref_type = ARGV[3] + git_default_branch = ARGV[4] + + # log to stderr so that stdout only contains the full tags + $stderr.puts "'#{repo_owner}', '#{repo_name}', '#{git_ref_name}', '#{git_ref_type}', '#{git_default_branch}'" + + image_name = get_image_name(username: repo_owner, project_name: repo_name) + + tags = + get_image_tags( + git_ref_name: git_ref_name, + git_ref_type: git_ref_type, + git_default_branch: git_default_branch, + semver: '0.0.0', + ) + .to_a + .map {|tag| "#{image_name}:#{tag}" } + .join(',') + + # log to stderr so that stdout only contains the full tags + $stderr.puts tags + + puts tags +end + +main() diff --git a/.github/workflows/scripts/lib.rb b/.github/workflows/scripts/lib.rb new file mode 100644 index 0000000..443fe32 --- /dev/null +++ b/.github/workflows/scripts/lib.rb @@ -0,0 +1,73 @@ +require 'set' + +# @param git_ref_name [String] +# @param git_ref_type [String] +# @param git_default_branch [String] +# @return [Set[String]] +def get_image_tags( + git_ref_name: nil, + git_ref_type: nil, + git_default_branch: nil, + semver: nil +) + versions = Set[] + + if git_ref_type == 'branch' + # add safe branch name + versions.add(git_ref_name.downcase.gsub(/[^a-z0-9._\n]+/, '-')) + elsif git_ref_type == 'tag' + # add version tag + versions.add(semver) + # TODO: check that this is actually latest + parsed = parse_semver(semver) + if parsed.pre == nil + versions.add(parsed.major.to_s) + versions.add("#{parsed.major}.#{parsed.minor}") + versions.add("#{parsed.major}.#{parsed.minor}.#{parsed.patch}") + end + + # TODO: if the tag was made on a non-default branch, we still tag with default branch + versions.add(git_default_branch) + end + + # TODO: if `tag`, check that this is actually latest + if git_ref_name == git_default_branch or git_ref_type == 'tag' + # Use Docker `latest` tag convention, only tagging `latest` on default branch. + versions.add('latest') + end + + return versions +end + +# @param registry [String] +# @param username [String] +# @param sub_image [String?] +# @return String +def get_image_name( + registry: 'ghcr.io', + username: nil, + project_name: nil, + sub_image: nil +) + username = username.downcase + project_name = project_name.downcase.gsub(/^docker-/, '') + + case registry + when 'ghcr.io' + container_repo = "#{registry}/#{username}/#{project_name}/#{sub_image ? sub_image : project_name}" + when 'docker.io' + container_repo = "#{registry}/#{username}/#{project_name}#{sub_image ? "-#{sub_image}" : ''}" + end +end + +Semver = Struct.new('Semver', :major, :minor, :patch, :pre, :build) + +# @param version [String] +# @return [Semver] +def parse_semver(version) + # Ruby extracts regex named groups to local vars (but only if the regex is inlined). + /^(?\d+)\.(?\d+)\.(?\d+)(?:-(?
[0-9A-Za-z\-.]+))?(?:\+(?[0-9A-Za-z\-]+))?$/ =~
+    version
+
+  Semver.new(major.to_i, minor.to_i, patch.to_i, pre, build)
+end
diff --git a/.github/workflows/scripts/test/get-image-tags.unit.rb b/.github/workflows/scripts/test/get-image-tags.unit.rb
new file mode 100755
index 0000000..f971d6a
--- /dev/null
+++ b/.github/workflows/scripts/test/get-image-tags.unit.rb
@@ -0,0 +1,153 @@
+#!/usr/bin/env ruby
+
+require 'test/unit'
+require 'json'
+require 'set'
+
+require_relative '../lib'
+
+class TestGetImageTags < Test::Unit::TestCase
+  def test_simple_branch
+    assert_equal(
+      Set['feat-foo-bar'],
+      get_image_tags(
+        git_ref_name: 'feat/foo-bar',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        semver: '1.0.0',
+      ),
+    )
+
+    assert_equal(
+      Set['latest', 'master'],
+      get_image_tags(
+        git_ref_name: 'master',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        semver: '1.0.0',
+      ),
+    )
+  end
+
+  def test_simple_tag
+    assert_equal(
+      Set['latest', 'master', '1.0.0', '1.0', '1'],
+      get_image_tags(
+        git_ref_name: '1.0.0',
+        git_ref_type: 'tag',
+        git_default_branch: 'master',
+        semver: '1.0.0',
+      ),
+    )
+  end
+
+  def test_pre_tag
+    assert_equal(
+      Set['latest', 'master', '1.0.0-pre'],
+      get_image_tags(
+        git_ref_name: '1.0.0',
+        git_ref_type: 'tag',
+        git_default_branch: 'master',
+        semver: '1.0.0-pre',
+      ),
+    )
+  end
+
+  def test_unsafe_branch_name
+    assert_equal(
+      Set['feat-foo-bar'],
+      get_image_tags(
+        git_ref_name: 'feat/Foo---bar',
+        git_ref_type: 'branch',
+        git_default_branch: 'master',
+        semver: '1.0.0',
+      ),
+    )
+  end
+end
+
+class TestGetImageName < Test::Unit::TestCase
+  def test_basic
+    assert_equal(
+      'ghcr.io/octocat/hello-world/hello-world',
+      get_image_name(
+        username: 'Octocat',
+        project_name: 'hello-world',
+      ),
+    )
+
+    assert_equal(
+      'ghcr.io/octocat/hello-world/hello-world',
+      get_image_name(
+        username: 'Octocat',
+        project_name: 'docker-hello-world',
+      ),
+    )
+
+    assert_equal(
+      'ghcr.io/octocat/hello-world/foobar',
+      get_image_name(
+        username: 'Octocat',
+        project_name: 'hello-world',
+        sub_image: 'foobar',
+      ),
+    )
+
+    assert_equal(
+      'ghcr.io/octocat/hello-world/foo',
+      get_image_name(
+        username: 'Octocat',
+        project_name: 'hello-world',
+        sub_image: 'foo'
+      ),
+    )
+
+    assert_equal(
+      'docker.io/octocat/hello-world',
+      get_image_name(
+        registry: 'docker.io',
+        username: 'Octocat',
+        project_name: 'hello-world',
+      ),
+    )
+
+    assert_equal(
+      'docker.io/octocat/hello-world-foo',
+      get_image_name(
+        registry: 'docker.io',
+        username: 'Octocat',
+        project_name: 'hello-world',
+        sub_image: 'foo'
+      ),
+    )
+  end
+end
+
+class TestParseSemver < Test::Unit::TestCase
+  def test_parse_basic
+    parsed = parse_semver('1.2.3')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal(nil, parsed.pre)
+    assert_equal(nil, parsed.build)
+  end
+
+  def test_parse_pre
+    parsed = parse_semver('1.2.3-p.re')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal('p.re', parsed.pre)
+    assert_equal(nil, parsed.build)
+  end
+
+  def test_parse_full_semver
+    parsed = parse_semver('1.2.3-p.re+build')
+    assert_equal(1, parsed.major)
+    assert_equal(2, parsed.minor)
+    assert_equal(3, parsed.patch)
+    assert_equal('p.re', parsed.pre)
+    assert_equal('build', parsed.build)
+  end
+end
diff --git a/Dockerfile b/Dockerfile
index ab83d6b..1cbe172 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,16 @@
-FROM frolvlad/alpine-python3
+# syntax=docker/dockerfile:1
+
+ARG BASE_IMAGE=docker.io/library/python:3-alpine
+ARG APP_DIR=/hoster
+
+FROM ${BASE_IMAGE}
 
 RUN pip3 install docker
-RUN mkdir /hoster
-WORKDIR /hoster
-ADD hoster.py /hoster/
+
+ARG APP_DIR=/hoster
+WORKDIR ${APP_DIR}
+
+ADD hoster.py ./
 
 CMD ["python3", "-u", "hoster.py"]
-
-
-
+# CMD "whoami"
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..f4fe945
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,12 @@
+# This file replaces `docker build -t docker-hoster .`
+# Run this with `docker-compose build`
+version: '3.8'
+
+services:
+  host-hostnames:
+    container_name: 'host-hostnames'
+    build:
+      context: '.'
+    volumes:
+      - '/var/run/docker.sock:/tmp/docker.sock:ro'
+      - '/etc/hosts:/tmp/hosts'
diff --git a/hoster.py b/hoster.py
index ae6c47e..2c00b81 100644
--- a/hoster.py
+++ b/hoster.py
@@ -39,9 +39,9 @@ def main():
 
     #listen for events to keep the hosts file updated
     for e in events:
-        if e["Type"]!="container": 
+        if e["Type"]!="container":
             continue
-        
+
         status = e["status"]
         if status =="start":
             container_id = e["id"]
@@ -71,16 +71,16 @@ def get_container_data(dockerClient, container_id):
     container_ip = info["NetworkSettings"]["IPAddress"]
     if info["Config"]["Domainname"]:
         container_hostname = container_hostname + "." + info["Config"]["Domainname"]
-    
+
     result = []
 
     for values in info["NetworkSettings"]["Networks"].values():
-        
-        if not values["Aliases"]: 
+
+        if not values["Aliases"]:
             continue
 
         result.append({
-                "ip": values["IPAddress"] , 
+                "ip": values["IPAddress"] ,
                 "name": container_name,
                 "domains": set(values["Aliases"] + [container_name, container_hostname])
             })
@@ -119,11 +119,11 @@ def update_hosts_file():
     #append all the domain lines
     if len(hosts)>0:
         lines.append("\n\n"+enclosing_pattern)
-        
+
         for id, addresses in hosts.items():
             for addr in addresses:
                 lines.append("%s    %s\n"%(addr["ip"],"   ".join(addr["domains"])))
-        
+
         lines.append("#-----Do-not-add-hosts-after-this-line-----\n\n")
 
     #write it on the auxiliar file
@@ -132,7 +132,7 @@ def update_hosts_file():
         aux_hosts.writelines(lines)
 
     #replace etc/hosts with aux file, making it atomic
-    shutil.move(aux_file_path, hosts_path)
+    shutil.copyfile(aux_file_path, hosts_path)
 
 
 def parse_args():