Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89512ddc30 | ||
|
|
10982e9c94 |
No files matched your search
@@ -0,0 +1,64 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def lines(value):
|
||||||
|
return [line.strip() for line in value.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def output(name, value):
|
||||||
|
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
|
||||||
|
stream.write(f'{name}<<GITEA_VALUE\n{value}\nGITEA_VALUE\n')
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(images, rules, sha):
|
||||||
|
result = []
|
||||||
|
for rule in lines(rules):
|
||||||
|
fields = dict(part.split('=', 1) for part in rule.split(',') if '=' in part)
|
||||||
|
if fields.get('enable', 'true').lower() == 'false':
|
||||||
|
continue
|
||||||
|
kind = fields.get('type')
|
||||||
|
if kind == 'sha':
|
||||||
|
tag = 'sha-' + sha[:7]
|
||||||
|
elif kind == 'raw':
|
||||||
|
tag = fields['value']
|
||||||
|
else:
|
||||||
|
raise ValueError('Unsupported metadata rule')
|
||||||
|
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
|
||||||
|
raise ValueError('Invalid image tag')
|
||||||
|
result.extend(image.lower() + ':' + tag for image in lines(images))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = sys.argv[1]
|
||||||
|
if mode == 'login':
|
||||||
|
subprocess.run(['docker', 'login', os.environ['REGISTRY'], '--username', os.environ['REGISTRY_USER'], '--password-stdin'], input=os.environ['REGISTRY_TOKEN'], text=True, check=True)
|
||||||
|
elif mode == 'metadata':
|
||||||
|
output('tags', '\n'.join(metadata(os.environ['IMAGES'], os.environ['TAG_RULES'], os.environ['SOURCE_SHA'])))
|
||||||
|
output('labels', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'])
|
||||||
|
elif mode == 'build':
|
||||||
|
command = ['docker', 'buildx', 'build', '--metadata-file', '/tmp/image-metadata.json']
|
||||||
|
for env, flag in [('TAGS', '--tag'), ('LABELS', '--label'), ('BUILD_ARGS', '--build-arg')]:
|
||||||
|
for value in lines(os.environ.get(env, '')):
|
||||||
|
command.extend([flag, value])
|
||||||
|
command += ['--label', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'], '--label', 'com.ermnvldmr.ci.run=' + os.environ['RUN_ID']]
|
||||||
|
if os.environ.get('PLATFORMS'):
|
||||||
|
command += ['--platform', os.environ['PLATFORMS']]
|
||||||
|
if os.environ.get('DOCKERFILE'):
|
||||||
|
command += ['--file', os.environ['DOCKERFILE']]
|
||||||
|
if os.environ.get('PUSH', 'false').lower() == 'true':
|
||||||
|
command.append('--push')
|
||||||
|
command.append(os.environ.get('CONTEXT', '.'))
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
output('digest', json.loads(Path('/tmp/image-metadata.json').read_text())['containerimage.digest'])
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown docker operation')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
name: Publish Docker Image
|
||||||
|
'on':
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*.*.*
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
restic_version:
|
||||||
|
description: Restic Version (e.g., 0.16.4)
|
||||||
|
required: true
|
||||||
|
default: latest
|
||||||
|
env:
|
||||||
|
REGISTRY: git.ermnvldmr.com
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: apex-debian13
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
|
||||||
|
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
|
||||||
|
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
|
||||||
|
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
|
||||||
|
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
|
||||||
|
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
|
||||||
|
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
|
||||||
|
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
|
||||||
|
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
|
||||||
|
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
|
||||||
|
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
|
||||||
|
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
|
||||||
|
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
|
||||||
|
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
|
||||||
|
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
|
||||||
|
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
|
||||||
|
env:
|
||||||
|
CHECKOUT_REF: ${{ github.sha }}
|
||||||
|
CHECKOUT_SUBMODULES: 'false'
|
||||||
|
CHECKOUT_TOKEN: ${{ github.token }}
|
||||||
|
PERSIST_CREDENTIALS: 'false'
|
||||||
|
HEXTRA_READ_KEY: ''
|
||||||
|
- name: Determine Version Tags
|
||||||
|
id: version
|
||||||
|
run: "if [ \"${{ github.event_name }}\" = \"workflow_dispatch\" ]; then\n VERSION=\"\
|
||||||
|
${{ github.event.inputs.restic_version }}\"\nelse\n # Strip 'v' prefix from\
|
||||||
|
\ tag\n VERSION=\"${GITHUB_REF#refs/tags/v}\"\nfi\necho \"RESTIC_VERSION=$VERSION\"\
|
||||||
|
\ >> $GITHUB_OUTPUT\necho \"IMAGE_TAG=$VERSION\" >> $GITHUB_OUTPUT\n"
|
||||||
|
- name: Set up QEMU
|
||||||
|
run: test -r /proc/sys/fs/binfmt_misc/qemu-aarch64
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
run: 'docker buildx version
|
||||||
|
|
||||||
|
docker buildx create --name apex-ci --driver docker-container --use
|
||||||
|
|
||||||
|
docker buildx inspect --bootstrap'
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
run: python3 .gitea/scripts/docker.py login
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USER: ${{ github.repository_owner }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
run: python3 .gitea/scripts/docker.py metadata
|
||||||
|
env:
|
||||||
|
IMAGES: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
TAG_RULES: 'type=raw,value=${{ steps.version.outputs.IMAGE_TAG }}
|
||||||
|
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
'
|
||||||
|
SOURCE_SHA: ${{ github.sha }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
run: python3 .gitea/scripts/docker.py build
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
|
LABELS: ${{ steps.meta.outputs.labels }}
|
||||||
|
BUILD_ARGS: 'RESTIC_VERSION=${{ steps.version.outputs.RESTIC_VERSION }}
|
||||||
|
|
||||||
|
'
|
||||||
|
DOCKERFILE: ''
|
||||||
|
PLATFORMS: linux/amd64,linux/arm64
|
||||||
|
PUSH: 'true'
|
||||||
|
CONTEXT: .
|
||||||
|
SOURCE_SHA: ${{ github.sha }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
name: Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
restic_version:
|
|
||||||
description: 'Restic Version (e.g., 0.16.4)'
|
|
||||||
required: true
|
|
||||||
default: 'latest'
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Determine Version Tags
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
VERSION="${{ github.event.inputs.restic_version }}"
|
|
||||||
else
|
|
||||||
# Strip 'v' prefix from tag
|
|
||||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
||||||
fi
|
|
||||||
echo "RESTIC_VERSION=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "IMAGE_TAG=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=${{ steps.version.outputs.IMAGE_TAG }}
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
RESTIC_VERSION=${{ steps.version.outputs.RESTIC_VERSION }}
|
|
||||||
@@ -2,6 +2,7 @@ package dockerclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
@@ -52,7 +53,19 @@ func (c *DefaultClient) ExecCommand(ctx context.Context, containerID string, cmd
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer attach.Close()
|
defer attach.Close()
|
||||||
_, _ = io.Copy(io.Discard, attach.Reader)
|
if _, err := io.Copy(io.Discard, attach.Reader); err != nil {
|
||||||
|
return fmt.Errorf("read exec output: %w", err)
|
||||||
|
}
|
||||||
|
inspect, err := c.cli.ContainerExecInspect(ctx, resp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect exec: %w", err)
|
||||||
|
}
|
||||||
|
if inspect.Running {
|
||||||
|
return fmt.Errorf("exec %s is still running after its output stream closed", resp.ID)
|
||||||
|
}
|
||||||
|
if inspect.ExitCode != 0 {
|
||||||
|
return fmt.Errorf("exec %s failed with exit code %d", resp.ID, inspect.ExitCode)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package dockerclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type streamErrorConn struct {
|
||||||
|
net.Conn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c streamErrorConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.Conn.Read(p)
|
||||||
|
if errors.Is(err, io.EOF) && c.err != nil {
|
||||||
|
return n, c.err
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecCommand(t *testing.T) {
|
||||||
|
streamErr := errors.New("exec stream interrupted")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
exitCode int
|
||||||
|
running bool
|
||||||
|
inspectStatus int
|
||||||
|
streamErr error
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{name: "successful exit", inspectStatus: http.StatusOK},
|
||||||
|
{name: "nonzero exit", exitCode: 23, inspectStatus: http.StatusOK, wantError: "exit code 23"},
|
||||||
|
{name: "still running", running: true, inspectStatus: http.StatusOK, wantError: "still running"},
|
||||||
|
{name: "inspect failure", inspectStatus: http.StatusInternalServerError, wantError: "inspect failed"},
|
||||||
|
{name: "stream failure", streamErr: streamErr, inspectStatus: http.StatusOK, wantError: streamErr.Error()},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var inspections atomic.Int32
|
||||||
|
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
local, remote := net.Pipe()
|
||||||
|
_ = local.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
_ = remote.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
t.Cleanup(func() { _ = local.Close(); _ = remote.Close() })
|
||||||
|
go func() {
|
||||||
|
defer remote.Close()
|
||||||
|
req, err := http.ReadRequest(bufio.NewReader(remote))
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = io.Copy(io.Discard, req.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
status, body := http.StatusCreated, `{"Id":"test-exec"}`
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/v1.47/containers/test-container/exec":
|
||||||
|
assert.Equal(t, http.MethodPost, req.Method)
|
||||||
|
case "/v1.47/exec/test-exec/start":
|
||||||
|
_, err = io.WriteString(remote, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nhook output")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
case "/v1.47/exec/test-exec/json":
|
||||||
|
inspections.Add(1)
|
||||||
|
status = tc.inspectStatus
|
||||||
|
body = fmt.Sprintf(`{"Running":%t,"ExitCode":%d}`, tc.running, tc.exitCode)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
body = `{"message":"inspect failed"}`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected Docker API request: %s", req.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(remote, "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", status, http.StatusText(status), len(body), body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}()
|
||||||
|
return streamErrorConn{Conn: local, err: tc.streamErr}, nil
|
||||||
|
}
|
||||||
|
cli, err := client.NewClientWithOpts(client.WithHost("tcp://docker.invalid:2375"), client.WithVersion("1.47"), client.WithDialContext(dial))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = cli.Close() })
|
||||||
|
c := &DefaultClient{cli: cli}
|
||||||
|
err = c.ExecCommand(context.Background(), "test-container", []string{"sh", "-c", "synthetic hook"})
|
||||||
|
if tc.wantError == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
assert.ErrorContains(t, err, tc.wantError)
|
||||||
|
assert.NotContains(t, fmt.Sprint(err), "synthetic hook")
|
||||||
|
}
|
||||||
|
if tc.streamErr != nil {
|
||||||
|
assert.ErrorIs(t, err, tc.streamErr)
|
||||||
|
assert.Zero(t, inspections.Load())
|
||||||
|
} else {
|
||||||
|
assert.EqualValues(t, 1, inspections.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"resticontainer/pkg/apptypes"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingHookClient struct {
|
||||||
|
mockDockerClient
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *failingHookClient) ExecCommand(ctx context.Context, containerID string, cmd []string) error {
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHooksAndBackup_PreHookFailurePreventsBackup(t *testing.T) {
|
||||||
|
hookErr := errors.New("exec failed with exit code 23")
|
||||||
|
client := &failingHookClient{err: hookErr}
|
||||||
|
targets := []*apptypes.Target{{ContainerID: "database", PreHook: "dump database", Stop: true}}
|
||||||
|
backupCalled := false
|
||||||
|
|
||||||
|
err := RunHooksAndBackup(context.Background(), client, targets, []string{"/dumps"}, nil, func(context.Context, []string) error {
|
||||||
|
backupCalled = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, hookErr)
|
||||||
|
assert.ErrorContains(t, err, "pre-hook failed for database")
|
||||||
|
assert.False(t, backupCalled)
|
||||||
|
assert.Empty(t, client.stops)
|
||||||
|
assert.Empty(t, client.starts)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user