diff --git a/.github/workflows/build-next.yml b/.github/workflows/build-next.yml index f3d1a66f..1913011a 100644 --- a/.github/workflows/build-next.yml +++ b/.github/workflows/build-next.yml @@ -73,10 +73,24 @@ jobs: push: true tags: ${{ env.DOCKER_IMAGE_NAME }}:next file: Dockerfile-prebuilt - + - name: Build and push container image (distroless) + uses: docker/build-push-action@v6 + id: container-build-push-distroless + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless + file: Dockerfile-distroless - name: Container image attestation uses: actions/attest-build-provenance@v2 with: subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-digest: ${{ steps.build-push-image.outputs.digest }} push-to-registry: true + - name: Container image attestation (distroless) + uses: actions/attest-build-provenance@v2 + with: + subject-name: "${{ env.DOCKER_IMAGE_NAME }}" + subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5ef400f..9fb3ee7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,12 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Set DOCKER_IMAGE_NAME run: | # Lowercase REPO_OWNER which is required for containers REPO_OWNER=${{ github.repository_owner }} DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id" echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV} - - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -53,17 +51,24 @@ jobs: type=semver,pattern={{version}},prefix=v type=semver,pattern={{major}}.{{minor}},prefix=v type=semver,pattern={{major}},prefix=v - + - name: Docker metadata (distroless) + id: meta-distroless + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.DOCKER_IMAGE_NAME }} + tags: | + type=semver,pattern={{version}}-distroless,prefix=v + type=semver,pattern={{major}}.{{minor}}-distroless,prefix=v + type=semver,pattern={{major}}-distroless,prefix=v - name: Install frontend dependencies working-directory: frontend run: npm ci - name: Build frontend working-directory: frontend run: npm run build - - name: Build binaries run: sh scripts/development/build-binaries.sh - - name: Build and push container image uses: docker/build-push-action@v6 id: container-build-push @@ -74,19 +79,32 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} file: Dockerfile-prebuilt - + - name: Build and push container image (distroless) + uses: docker/build-push-action@v6 + id: container-build-push-distroless + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-distroless.outputs.tags }} + labels: ${{ steps.meta-distroless.outputs.labels }} + file: Dockerfile-distroless - name: Binary attestation uses: actions/attest-build-provenance@v2 with: subject-path: "backend/.bin/pocket-id-**" - - name: Container image attestation uses: actions/attest-build-provenance@v2 with: subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-digest: ${{ steps.container-build-push.outputs.digest }} push-to-registry: true - + - name: Container image attestation (distroless) + uses: actions/attest-build-provenance@v2 + with: + subject-name: "${{ env.DOCKER_IMAGE_NAME }}" + subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }} + push-to-registry: true - name: Upload binaries to release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 7731d131..51511347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,5 +48,7 @@ RUN chmod +x /app/pocket-id && \ EXPOSE 1411 ENV APP_ENV=production +HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ] + ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"] CMD ["/app/pocket-id"] diff --git a/Dockerfile-distroless b/Dockerfile-distroless new file mode 100644 index 00000000..dfce2b74 --- /dev/null +++ b/Dockerfile-distroless @@ -0,0 +1,18 @@ +# This Dockerfile embeds a pre-built binary for the given Linux architecture +# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only" + +FROM gcr.io/distroless/static-debian12:nonroot + +# TARGETARCH can be "amd64" or "arm64" +ARG TARGETARCH + +WORKDIR /app + +COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id + +EXPOSE 1411 +ENV APP_ENV=production + +HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ] + +CMD ["/app/pocket-id"] diff --git a/Dockerfile-prebuilt b/Dockerfile-prebuilt index a8a517d1..93c979d9 100644 --- a/Dockerfile-prebuilt +++ b/Dockerfile-prebuilt @@ -1,5 +1,5 @@ # This Dockerfile embeds a pre-built binary for the given Linux architecture -# Binaries must be built using ""./scripts/development/build-binaries.sh --docker-only" +# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only" FROM alpine @@ -16,5 +16,7 @@ COPY ./scripts/docker /app/docker EXPOSE 1411 ENV APP_ENV=production +HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ] + ENTRYPOINT ["/app/docker/entrypoint.sh"] CMD ["/app/pocket-id"] diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index ee70cf98..ec878b8c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -11,13 +11,9 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/utils" - "github.com/pocket-id/pocket-id/backend/internal/utils/signals" ) -func Bootstrap() error { - // Get a context that is canceled when the application is stopping - ctx := signals.SignalContext(context.Background()) - +func Bootstrap(ctx context.Context) error { initApplicationImages() // Initialize the tracer and metrics exporter diff --git a/backend/internal/cmds/healthcheck.go b/backend/internal/cmds/healthcheck.go new file mode 100644 index 00000000..d32bd1f4 --- /dev/null +++ b/backend/internal/cmds/healthcheck.go @@ -0,0 +1,83 @@ +package cmds + +import ( + "context" + "log/slog" + "net/http" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +type healthcheckFlags struct { + Endpoint string + Verbose bool +} + +func init() { + var flags healthcheckFlags + + healthcheckCmd := &cobra.Command{ + Use: "healthcheck", + Short: "Performs a healthcheck of a running Pocket ID instance", + Run: func(cmd *cobra.Command, args []string) { + start := time.Now() + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + url := flags.Endpoint + "/healthz" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + slog.ErrorContext(ctx, + "Failed to create request object", + "error", err, + "url", url, + "ms", time.Since(start).Milliseconds(), + ) + os.Exit(1) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + slog.ErrorContext(ctx, + "Failed to perform request", + "error", err, + "url", url, + "ms", time.Since(start).Milliseconds(), + ) + os.Exit(1) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + if err != nil { + slog.ErrorContext(ctx, + "Healthcheck failed", + "status", res.StatusCode, + "url", url, + "ms", time.Since(start).Milliseconds(), + ) + os.Exit(1) + } + } + + if flags.Verbose { + slog.InfoContext(ctx, + "Healthcheck succeeded", + "status", res.StatusCode, + "url", url, + "ms", time.Since(start).Milliseconds(), + ) + } + }, + } + + healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID") + healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode") + + rootCmd.AddCommand(healthcheckCmd) +} diff --git a/backend/internal/cmds/root.go b/backend/internal/cmds/root.go index d6f6bb5d..f3488cbb 100644 --- a/backend/internal/cmds/root.go +++ b/backend/internal/cmds/root.go @@ -1,12 +1,14 @@ package cmds import ( + "context" "log/slog" "os" "github.com/spf13/cobra" "github.com/pocket-id/pocket-id/backend/internal/bootstrap" + "github.com/pocket-id/pocket-id/backend/internal/utils/signals" ) var rootCmd = &cobra.Command{ @@ -15,7 +17,7 @@ var rootCmd = &cobra.Command{ Long: "By default, this command starts the pocket-id server.", Run: func(cmd *cobra.Command, args []string) { // Start the server - err := bootstrap.Bootstrap() + err := bootstrap.Bootstrap(cmd.Context()) if err != nil { slog.Error("Failed to run pocket-id", "error", err) os.Exit(1) @@ -24,7 +26,10 @@ var rootCmd = &cobra.Command{ } func Execute() { - err := rootCmd.Execute() + // Get a context that is canceled when the application is stopping + ctx := signals.SignalContext(context.Background()) + + err := rootCmd.ExecuteContext(ctx) if err != nil { os.Exit(1) } diff --git a/docker-compose.yml b/docker-compose.yml index 8499cbe1..c48e7d76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,8 @@ services: - "./data:/app/data" # Optional healthcheck healthcheck: - test: "curl -f http://localhost:1411/healthz" + test: [ "CMD", "/app/pocket-id", "healthcheck" ] interval: 1m30s timeout: 5s retries: 2 - start_period: 10s \ No newline at end of file + start_period: 10s