Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
docs
examples
scripts
!scripts/ci/verify-image.sh
test
libjq

Expand All @@ -10,4 +11,4 @@ libjq
Dockerfile
LICENSE
*.md
*.png
*.png
78 changes: 78 additions & 0 deletions .github/workflows/images.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# - Detect base-image changes in Dockerfile FROM instructions.
# - Build amd64, arm64 and arm/v7 only when bases change or on manual runs.
# - Check base-image and binary architectures and run smoke tests without publishing images.
name: Base image architecture checks
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:

permissions:
contents: read

jobs:
changes:
name: Detect base image changes
runs-on: ubuntu-latest
outputs:
build: ${{ steps.check.outputs.build }}
steps:
- name: Check out shell-operator code
if: github.event_name == 'pull_request'
uses: actions/checkout@v7
- name: Decide whether to build images
id: check
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before || github.event.pull_request.base.sha }}
AFTER_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ "$EVENT_NAME" = workflow_dispatch ]; then
echo "▶️ Manual run requested: building and checking all platforms."
echo "build=true" >> "$GITHUB_OUTPUT"
exit 0
fi

# Compare this push so unrelated commits do not repeat the full build.
git fetch --no-tags --depth=1 origin "$BEFORE_SHA" "$AFTER_SHA"
git show "$BEFORE_SHA:Dockerfile" | awk 'toupper($1) == "FROM" { $1=$1; print }' > "$RUNNER_TEMP/base-images-before"
git show "$AFTER_SHA:Dockerfile" | awk 'toupper($1) == "FROM" { $1=$1; print }' > "$RUNNER_TEMP/base-images-after"

if cmp -s "$RUNNER_TEMP/base-images-before" "$RUNNER_TEMP/base-images-after"; then
echo "⏭️ Base images unchanged. Skipping architecture checks."
echo "build=false" >> "$GITHUB_OUTPUT"
else
echo "🔄 Base image changes detected."
echo "🚀 Full build and architecture checks requested for all platforms."
echo "build=true" >> "$GITHUB_OUTPUT"
fi

build:
needs: changes
if: needs.changes.outputs.build == 'true'
name: Build and verify (${{ matrix.platform }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64, linux/arm/v7]
steps:
- name: Check out shell-operator code
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/[email protected]
with:
platforms: arm64,arm
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and verify image
env:
PLATFORM: ${{ matrix.platform }}
run: |
echo "🏗️ Building $PLATFORM image with architecture verification enabled."
docker buildx build \
--platform "$PLATFORM" \
--build-arg appVersion=ci \
--output type=cacheonly \
.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
steps:
- uses: actions/setup-python@v6
with:
python-version: 3.8
python-version: "3.12"
- name: Check out addon-operator code
uses: actions/checkout@v6
- name: Run codespell
Expand Down
17 changes: 9 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
# Prebuilt jq from deckhouse base images (v1.0.40).
FROM --platform=${TARGETPLATFORM:-linux/amd64} registry.deckhouse.io/container-factory@sha256:4b36dcf53c35b50e0afbc445232713aff15f788a61b832cd720bf9e88fc9fba8 AS libjq

# Go builder stage (builder/golang-alpine, Go 1.26.4 on alpine 3.22).
FROM --platform=${TARGETPLATFORM:-linux/amd64} registry.deckhouse.io/container-factory@sha256:193e8ed6cd7fc19015ab615ccf92d0fe02471e66e3e5abf560b3a87fb05bdb62 AS builder
FROM --platform=${TARGETPLATFORM:-linux/amd64} golang:1.26.4-alpine3.23@sha256:18b460dd17542c2ba43299a633cf6ebfc1115101509531471d7cfce1019af083 AS builder

ARG appVersion=latest

Expand Down Expand Up @@ -32,8 +28,7 @@ RUN GOOS=linux \
-o shell-operator \
./cmd/shell-operator

# Final runtime image (builder/alpine 3.22 from deckhouse base images v1.0.40).
FROM --platform=${TARGETPLATFORM:-linux/amd64} registry.deckhouse.io/container-factory@sha256:8fa8cf713bf8cfc9038901e5b2fbc97d0403794d834dc4a619e9e81312a6feef
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.23@sha256:ff7f0c727dd796aa36787bfcd9a64bf35a6ce72a73451b3359fde849b6703d63

ARG TARGETPLATFORM
ARG kubectlVersion=v1.34.8
Expand All @@ -44,6 +39,7 @@ RUN apk --no-cache add \
bash \
sed \
tini \
jq \
curl

# Determine kubectl architecture and download
Expand All @@ -58,9 +54,14 @@ RUN mkdir /hooks
# Copy necessary files
ADD frameworks/shell /frameworks/shell
ADD shell_lib.sh /
COPY --from=libjq /usr/bin/jq /usr/bin/jq
COPY --from=builder /app/shell-operator /

# Reject incorrect architectures before the image can be published.
RUN --mount=type=bind,source=scripts/ci/verify-image.sh,target=/tmp/verify-image.sh \
apk add --no-cache --virtual .image-verification file && \
sh /tmp/verify-image.sh "${TARGETPLATFORM:-linux/amd64}" && \
apk del .image-verification

# Set working directory
WORKDIR /

Expand Down
153 changes: 80 additions & 73 deletions pkg/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
stdjson "encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -196,6 +197,8 @@ func (e *Executor) RunAndLogLines(ctx context.Context, logLabels map[string]stri
defer unregisterPID(e.cmd.Process.Pid)

err := e.cmd.Wait()
plo.flush()
ple.flush()
if err != nil {
if len(stdErr.Bytes()) > 0 {
return nil, &StderrError{Message: stdErr.String()}
Expand Down Expand Up @@ -229,105 +232,109 @@ type proxyLogger struct {
proxyJsonLogKey string
logger *log.Logger
buf []byte
jsonBuf []byte
}

func (pl *proxyLogger) Write(p []byte) (int, error) {
if !pl.logProxyHookJSON {
pl.writerScanner(p)

return len(p), nil
}

// join all parts of json
pl.buf = append(pl.buf, p...)

var line interface{}
err := json.Unmarshal(pl.buf, &line)
if err != nil {
if err.Error() == "unexpected end of JSON input" {
return len(p), nil
for len(pl.buf) > 0 {
lineSize := bytes.IndexByte(pl.buf, '\n') + 1
if !pl.hasPendingJSON() && len(pl.buf) >= bufio.MaxScanTokenSize && (lineSize == 0 || lineSize > bufio.MaxScanTokenSize) {
lineSize = bufio.MaxScanTokenSize
}
if lineSize == 0 {
break
}

pl.logger.Debug("output is not json", log.Err(err))
pl.writerScanner(p)

return len(p), nil
pl.logLine(pl.buf[:lineSize])
pl.buf = pl.buf[lineSize:]
}

logMap, ok := line.(map[string]interface{})
defer func() {
pl.buf = pl.buf[:0]
}()

if !ok {
pl.logger.Debug("json log line not map[string]interface{}", slog.Any(pkg.LogKeyLine, line))

// fall back to using the logger
pl.logger.Info(string(p))

return len(p), nil
if len(pl.buf) == 0 {
pl.buf = nil
}

pl.mergeAndLogInputLog(pl.ctx, logMap, "hook")

return len(p), nil
}

func (pl *proxyLogger) writerScanner(p []byte) {
scanner := bufio.NewScanner(bytes.NewReader(p))
scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), bufio.MaxScanTokenSize)
func (pl *proxyLogger) hasPendingJSON() bool {
if len(pl.jsonBuf) > 0 {
return true
}
line := bytes.TrimLeft(pl.buf, " \t\r\n")
return bytes.HasPrefix(line, []byte("{")) || (pl.logProxyHookJSON && (bytes.HasPrefix(line, []byte("[")) || bytes.HasPrefix(line, []byte("\""))))
}

var jsonBuf []string
// Split large entries into chunks
chunkSize := bufio.MaxScanTokenSize // 64KB
splitFunc := func(data []byte, atEOF bool) (int, []byte, error) {
if len(data) >= chunkSize {
return chunkSize, data[:chunkSize], nil
}
return bufio.ScanLines(data, atEOF)
// flush logs the final unterminated record after the output stream ends.
func (pl *proxyLogger) flush() {
if len(pl.buf) > 0 {
pl.logLine(pl.buf)
pl.buf = nil
}
scanner.Split(splitFunc)
// Scan the input and write it to the logger using the specified print function
for scanner.Scan() {
// Prevent empty logging
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if len(pl.jsonBuf) > 0 {
pl.logText(string(pl.jsonBuf))
pl.jsonBuf = nil
}
}

if len(jsonBuf) > 0 || strings.HasPrefix(line, "{") {
jsonBuf = append(jsonBuf, line)
joined := strings.Join(jsonBuf, "\n")
if err := pl.tryLogJSON(joined); err == nil {
jsonBuf = nil
}
continue
}
func (pl *proxyLogger) logLine(raw []byte) {
line := strings.TrimSpace(string(raw))
if line == "" && len(pl.jsonBuf) == 0 {
return
}

if len(line) > 10000 {
line = fmt.Sprintf("%s:truncated", line[:10000])
// JSON records can span both physical lines and separate Write calls.
if len(pl.jsonBuf) > 0 || strings.HasPrefix(line, "{") || (pl.logProxyHookJSON && (strings.HasPrefix(line, "[") || strings.HasPrefix(line, "\""))) {
pl.jsonBuf = append(pl.jsonBuf, raw...)
err := pl.tryLogJSON(string(pl.jsonBuf))
if errors.Is(err, io.ErrUnexpectedEOF) {
return
}

if err := pl.tryLogJSON(line); err == nil {
continue
if err != nil {
pl.logText(string(pl.jsonBuf))
}
pl.logger.Info(line)
pl.jsonBuf = nil
return
}

if err := pl.tryLogJSON(string(raw)); err == nil {
return
}
// If there was an error while scanning the input, log an error
if err := scanner.Err(); err != nil {
pl.logger.Error("reading from scanner", log.Err(err))
pl.logText(line)
}

func (pl *proxyLogger) logText(line string) {
line = strings.TrimSpace(line)
if len(line) > 10000 {
line = fmt.Sprintf("%s:truncated", line[:10000])
}
pl.logger.Info(line)
}

// tryLogJSON tries to parse the string as JSON and log it if it succeeds
// tryLogJSON logs a complete JSON record with the hook's structured fields.
func (pl *proxyLogger) tryLogJSON(s string) error {
var m map[string]interface{}
err := json.Unmarshal([]byte(s), &m)
if err == nil {
pl.mergeAndLogInputLog(pl.ctx, m, "hook")
var value interface{}
if err := json.Unmarshal([]byte(s), &value); err != nil {
// go-json reports some incomplete documents as syntax errors.
var raw stdjson.RawMessage
if decodeErr := stdjson.NewDecoder(strings.NewReader(s)).Decode(&raw); errors.Is(decodeErr, io.ErrUnexpectedEOF) {
return io.ErrUnexpectedEOF
}
return err
}

logMap, ok := value.(map[string]interface{})
if ok || (value == nil && !pl.logProxyHookJSON) {
pl.mergeAndLogInputLog(pl.ctx, logMap, "hook")
return nil
}
return fmt.Errorf("failed to parse json: %w", err)
if !pl.logProxyHookJSON {
return errors.New("json log line is not an object")
}

pl.logger.Debug("json log line not map[string]interface{}", slog.Any(pkg.LogKeyLine, value))
pl.logger.Info(s)
return nil
}

// level = level
Expand Down
Loading
Loading