diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3553cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env* +**/.git +**/.github +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/*Dockerfile +**/docs +LICENSE +README.md diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml new file mode 100644 index 0000000..c3a6736 --- /dev/null +++ b/.github/workflows/integration-test.yaml @@ -0,0 +1,41 @@ +--- +name: Integration Test +"on": + workflow_dispatch: + pull_request: + branches: + - main + paths: + - "integration/**" + - "**/*.go" + push: + branches: + - main + +permissions: + id-token: write + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-test: + name: Run integration tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Just + uses: ./.github/actions/setup-workspace + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run integration tests + working-directory: ./integration + run: | + just build + just test-all diff --git a/cli/internal/templating/engine.go b/cli/internal/templating/engine.go index 4558798..b4ca0fd 100644 --- a/cli/internal/templating/engine.go +++ b/cli/internal/templating/engine.go @@ -14,13 +14,23 @@ import ( "github.com/jgfranco17/hackstack/cli/internal/fileutils" "github.com/jgfranco17/hackstack/cli/internal/logging" + "github.com/sirupsen/logrus" ) +const ( + extensionTemplate = ".j2" + extensionRawCopy = ".copy" +) + +// Engine is the entity responsible for rendering embedded template +// files with provided source data. type Engine struct { Files fs.FS Data CLIProject } +// NewEngine creates a new templating engine instance with the provided +// embedded files and source data. func NewEngine(files fs.FS, data CLIProject) *Engine { return &Engine{ Files: files, @@ -28,6 +38,9 @@ func NewEngine(files fs.FS, data CLIProject) *Engine { } } +// Render processes the embedded template files and writes the output to the specified directory. +// It walks through all files in the embedded FS, rendering templates and copying raw files as +// needed. The function returns an error if any step of the rendering process fails. func (e *Engine) Render(ctx context.Context, outputPath string) error { logger := logging.FromContext(ctx).WithField("module", "templating") @@ -36,7 +49,7 @@ func (e *Engine) Render(ctx context.Context, outputPath string) error { walker := func(path string, d fs.DirEntry, err error) error { if err != nil { - return fmt.Errorf("walk error at %q: %w", path, err) + return fmt.Errorf("walk error at %s: %w", path, err) } if d.IsDir() { return nil @@ -46,16 +59,22 @@ func (e *Engine) Render(ctx context.Context, outputPath string) error { var work func() error switch { - case strings.HasSuffix(path, ".j2"): - destPath = strings.TrimSuffix(destPath, ".j2") + case strings.HasSuffix(path, extensionTemplate): + destPath = strings.TrimSuffix(destPath, extensionTemplate) work = func() error { - logger.WithField("file", path).Trace("Rendering from template") + logger.WithFields(logrus.Fields{ + "source": path, + "destination": destPath, + }).Trace("Rendering from template") return renderTemplate(e.Files, path, destPath, e.Data) } - case strings.HasSuffix(path, ".copy"): - destPath = strings.TrimSuffix(destPath, ".copy") + case strings.HasSuffix(path, extensionRawCopy): + destPath = strings.TrimSuffix(destPath, extensionRawCopy) work = func() error { - logger.WithField("file", path).Trace("Copying file") + logger.WithFields(logrus.Fields{ + "source": path, + "destination": destPath, + }).Trace("Copying raw file") return fileutils.CopyFile(e.Files, path, destPath) } default: diff --git a/examples/cli.yaml b/examples/datasource.yaml similarity index 100% rename from examples/cli.yaml rename to examples/datasource.yaml diff --git a/integration/Dockerfile b/integration/Dockerfile new file mode 100644 index 0000000..a693233 --- /dev/null +++ b/integration/Dockerfile @@ -0,0 +1,45 @@ +# ========== CLI BUILD STAGE ========== +ARG GO_VERSION=1.25 +FROM golang:${GO_VERSION}-alpine AS build + +WORKDIR /src +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,source=go.mod,target=go.mod \ + go mod download -x +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod/ \ + CGO_ENABLED=0 go build -o /bin/hackstack . + +# ========== CLI SETUP STAGE ========== +FROM ubuntu:22.04 AS env-setup +SHELL ["/bin/bash", "-c"] + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates xz-utils && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /bin/hackstack /bin/hackstack + +# Tests should use non-root to simulate a real user. +ARG USERNAME=testuser +ARG USER_ID=1000 +ARG GROUP_ID=1000 +RUN addgroup --gid $GROUP_ID ${USERNAME} && \ + adduser --disabled-password --gecos '' --uid ${USER_ID} --gid ${GROUP_ID} ${USERNAME} + +FROM env-setup AS workspace-setup +USER ${USERNAME} +ENV HOME=/home/${USERNAME} +ARG PROJECT_DIR="${HOME}/projects" +WORKDIR ${PROJECT_DIR} + +COPY examples/ ./examples/ +RUN mkdir -p "${PROJECT_DIR}/backend" "${PROJECT_DIR}/cli" + +# ========== APP RUN STAGE ========== +FROM workspace-setup AS app +SHELL ["/usr/bin/env", "bash", "-c"] + +ENTRYPOINT [ "/bin/hackstack" ] +CMD ["--help"] diff --git a/integration/docker-compose.yaml b/integration/docker-compose.yaml new file mode 100644 index 0000000..57e08fc --- /dev/null +++ b/integration/docker-compose.yaml @@ -0,0 +1,8 @@ +# Integration test Docker suite +services: + hackstack: + container_name: hackstack + image: hackstack:latest + build: + context: .. + dockerfile: integration/Dockerfile diff --git a/integration/justfile b/integration/justfile new file mode 100644 index 0000000..d430115 --- /dev/null +++ b/integration/justfile @@ -0,0 +1,55 @@ +# INTEGRATION TEST SCRIPTS + +CAPTURE_OUTPUT_FILE := "output.txt" + +# Default target to list available commands +_default: + @just --list --unsorted + +########################################################## +# TEST WORKSPACE SETUP +########################################################## + +# Build the services in Docker +build: + docker compose build + +# Workspace teardown +clean: + -docker compose down + -rm {{ CAPTURE_OUTPUT_FILE }} + +########################################################## +# INTERACTIVE COMMANDS +########################################################## + +# Run the CLI image with envs +hackstack *args: + @docker compose run \ + --remove-orphans --rm \ + hackstack {{ args }} + +########################################################## +# TEST EXECUTION +########################################################## + +# Run the full test suite +test-all: test-base test-build + @echo "Hackstack CLI integration test completed!" + +# Base tests to verify the CLI is working and accessible +test-base: + #!/usr/bin/env bash + just hackstack --version | grep -n "hackstack" + just hackstack --help 2>&1 | grep -n "hackstack" + +# Test the build command with a sample datasource +test-build: + #!/usr/bin/env bash + just hackstack build --help + just hackstack -vvv build backend \ + --output "/home/testuser/projects/backend" \ + --source examples/datasource.yaml + just hackstack -vvv build cli \ + --output "/home/testuser/projects/cli" \ + --source examples/datasource.yaml