diff --git a/.agents/skills/greptimedb-development-docker-image/SKILL.md b/.agents/skills/greptimedb-development-docker-image/SKILL.md new file mode 100644 index 0000000000..02a9a61d87 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/SKILL.md @@ -0,0 +1,368 @@ +--- +name: greptimedb-development-docker-image +description: Builds a development-only GreptimeDB Docker image from a local debug binary for local-cluster testing, and optionally pushes it to a development registry. Use when the user asks to package, build, tag, publish, or cross-build a non-release GreptimeDB or GreptimeDB Enterprise image for debugging. +compatibility: Requires Docker with Buildx for cross-platform builds; Podman is supported for native-platform builds. +metadata: + protocols: docker buildx podman + platforms: linux-amd64 linux-arm64 +--- + +# GreptimeDB Development Docker Image + +## Goal + +Package a locally built GreptimeDB binary into a **development-only** Docker +image for debugging and local-cluster testing, optionally push it to a +development registry, and retain the non-secret image settings in `.env` for +the next build. It is **not** a release-image workflow and must not be used to +publish a production or release artifact. + +This skill follows the documented development-image build procedure: + +- Build `greptime` with Cargo's `nightly` profile. +- Copy the target binary into the Docker build context as `./greptime`. +- Build the supplied `Dockerfile`, which uses Ubuntu 24.04 only as the runtime + base image and exposes the `greptime` binary as its entrypoint. + +## Inputs + +Collect or discover the following before any build or push: + +| Input | Discovery and rule | +| --- | --- | +| Source repository | Treat the directory in which this skill is invoked as the default workspace. Include it as an editable field in the batch configuration; do not ask a separate confirmation. Do not assume open-source versus Enterprise. | +| Edition and binary | Default to `greptime`; use the binary name the user requests for Enterprise builds. The copied Docker-context filename must always be `greptime`. | +| Cargo profile and binary source | Include this in the batch configuration. Default to rebuilding `nightly`; reuse an existing binary only after the user explicitly accepts that its freshness is unverified. | +| Target platform | Preselect `linux/amd64` unless Docker's server is `linux/arm64`, then preselect `linux/arm64`. Let the user override it in the batch configuration. Ubuntu 24.04 is the runtime base image, not a target-platform choice. Each image has exactly one target platform, but it may differ from the host platform. | +| Build mode | Ask whether the user wants a locally loadable debug image or a registry push. | +| Registry/repository | Read `IMAGE_REGISTRY` and `IMAGE_REPOSITORY` only from the selected workspace's `.env` and prefill them in the batch configuration. Example: `registry.example.com/team` + `greptimedb-dev`. | +| Tag | Read `IMAGE_TAG` only from the selected workspace's `.env` and prefill it in the batch configuration. When the tag exists in the selected registry, preselect an incremented version. | + +Use the image reference `${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${IMAGE_TAG}`. +If the registry is intentionally empty, omit its slash rather than producing a +leading slash. + +## Interactive Workflow + +Use the platform's interactive prompt component for every question, selection, +and confirmation. Do not ask an open-ended text question when a single-choice, +multi-select, or confirmation component can represent the decision. If the +platform does not provide an interactive component, use the equivalent numbered +or lettered prompt below and wait for input before continuing. + +### Batch configuration + +Minimize user round trips: run the collector first against the invocation +directory, then use one interactive form or batched prompt to collect workspace, +edition/binary, profile/reuse-or-rebuild choice, target platform, build mode, +registry/repository, tag, and whether to inspect the configured registry tag. +Use the invocation directory as the editable workspace default. Prefill +collector values and mark recommended defaults. If the user changes workspace, +rerun the collector for that workspace without asking another configuration +question. Only ask a follow-up if information is missing or invalid, or a safety +gate is required. Treat unchanged prefilled values as accepted. + +Keep separate confirmation components only for elevated privileges: `sudo`, +package installation, and privileged QEMU setup. Use one final confirmation for +all non-privileged selected work. + +Always offer **Cancel** for an action that can write state, build, push, or +require elevated privileges. Display the relevant preview before the user +confirms it: source/binary path, `file` architecture result, platform(s), image +reference, build mode, and non-secret `.env` values. + +### Batch configuration fields + +Use single-choice controls inside the one batch form for mutually exclusive +choices: + +| Decision | Required choices | +| --- | --- | +| Cargo output | Rebuild `nightly` (recommended), reuse an existing binary only with a freshness-unverified acknowledgement, choose a profile/binary | +| Target platform | `linux/amd64` (default unless Docker server is arm64), `linux/arm64` (default when Docker server is arm64). The selected image may be cross-built with Buildx. | +| Build mode | Load locally (recommended), push to development registry | +| Registry/repository | Use `.env` defaults, enter new values | +| Tag | Use proposed increment when the configured tag exists, enter a version | + +When push is selected and the image configuration is complete, automatically +run the read-only registry-tag check. It may return `unknown`; do not treat that +as a missing tag. + +Use one final confirmation for all selected non-privileged operations: + +- persist changed non-secret image settings to `.env`; +- run the selected Cargo build or reuse the resolved binary; +- prepare the Docker context; +- build, verify, and, when selected, push the image. + +The final confirmation must clearly state: **“This creates a development and +local-cluster test image, not a release or production artifact.”** + +For a real multi-select only, use lettered choices (`[A]`, `[B]`) and accept +`all`, `none`, or `cancel`; otherwise use single-choice components. + +### Preflight context collection + +Before asking the profile, registry, or tag questions, run the bundled, +read-only collector. It works on macOS and Linux, does not invoke `cargo build`, +and emits JSON that can be shown or summarized to the user: + +```bash +python3 /scripts/collect_context.py \ + --source \ + --bin greptime \ + --profile nightly \ + [--target ] +``` + +Use its report to identify: + +- missing `IMAGE_REGISTRY`, `IMAGE_REPOSITORY`, or `IMAGE_TAG` values in the + selected workspace's `.env`; +- Cargo binary targets available in the workspace and whether the requested + `--bin` exists; +- the resolved Cargo target directory, expected profile output path, and whether + that binary already exists; and +- macOS/Linux host architecture plus Docker's server platform when Docker is + available. + +When push is selected and image configuration is complete, rerun the collector +with `--check-registry-tag` and all selected image values. It uses the selected +engine's read-only manifest inspection and returns `exists` or `unknown`; +`unknown` includes an unavailable registry, an absent tag, or missing +authentication and must not be treated as an absent tag. + +```bash +python3 /scripts/collect_context.py \ + --source \ + --bin \ + --profile \ + --engine \ + --registry \ + --repository \ + --tag \ + --check-registry-tag +``` + +When the status is `exists` and the tag ends in a number, preselect the +collector's `candidate_tag` as the next development tag. The user must still +confirm it before `.env` is updated or an image is built. Never use automatic +incrementing as permission to overwrite or push an existing image. + +If Cargo metadata or the requested binary target is unavailable, stop before +building and ask the user to correct the source path or binary target. + +The profile/binary choice is a field in the batch configuration, not a separate +question. Its default is the existing `nightly` output when present: + +```text +Question: Which Cargo build output should package this image? +Options: +- Rebuild nightly (Recommended): run Cargo with `--profile nightly`, then use its output. +- Reuse nightly output: use the existing output only after showing its path, modification time, size, and matching platform; freshness is unverified. +- Choose profile or binary: provide a different Cargo profile or an explicit binary path. +``` + +Do not infer that a binary under `target/debug`, `target/release`, or another +profile is suitable. For an existing binary, show its resolved path and run +`file` before asking for final build confirmation. + +## Environment State + +The image configuration lives only in the selected workspace's `.env`. The +collector reads only the following keys and never reads or displays other `.env` +values: + +```dotenv +IMAGE_REGISTRY=registry.example.com/team +IMAGE_REPOSITORY=greptimedb-dev +IMAGE_TAG=dev-001 +``` + +1. Read `.env` if it exists. Ignore blank lines and `#` comments. +2. Show the discovered registry, repository, and proposed tag to the user. +3. Include registry/repository and tag in the batch configuration even when + values already exist; saved values are defaults, not authorization to reuse + them. +4. Compare the selected configuration with the three current managed values. If + all match, skip `.env` confirmation and do not update the file. Otherwise, + preview only the changed `IMAGE_*` values, request confirmation, and update + the file while preserving unrelated entries and comments. +5. Never put registry credentials, access tokens, passwords, or `docker login` + output in `.env`, build commands, logs, or responses. Ask the user to log in + themselves if a push needs authentication. + +For a repeated build, offer an incremented tag before asking. Use +`scripts/next_image_tag.py --tag ` to calculate it. It increments +the final numeric component while preserving zero padding: + +```text +weny-2025-0715-01 -> weny-2025-0715-02 +v0.1.4 -> v0.1.5 +debug-009 -> debug-010 +``` + +If the saved tag has no trailing number, ask the user for the next tag rather +than inventing a versioning convention. Do not overwrite an existing image tag +without explicit user confirmation. + +Only if a managed value is missing or differs, persist the values with the +bundled helper after confirmation instead of hand-editing the file: + +```bash +python3 /scripts/update_image_env.py \ + --env /.env \ + --registry \ + --repository \ + --tag +``` + +## Build Procedure + +### 1. Inspect the host and container tooling + +Run these checks and report the selected path: + +```bash +uname -s +uname -m +docker version --format '{{.Server.Os}}/{{.Server.Arch}}' +docker buildx version +docker buildx inspect --bootstrap +``` + +For a native-platform build, Podman can be used if Docker is unavailable. For +any non-native request, require Docker Buildx. Do not silently fall back to a +native build when the requested image platform differs. + +If the requested target is unavailable, stop and ask the user to configure a +Buildx builder and cross-compilation toolchain externally. Do not automate +privileged QEMU or binfmt setup, and never run an unpinned privileged image. + +### 2. Build the executable for the image platform + +From the source checkout, build the requested binary. For open-source amd64: + +```bash +/scripts/build_binary.sh \ + --source --package cmd --bin greptime --profile nightly +``` + +For open-source arm64: + +```bash +/scripts/build_binary.sh \ + --source \ + --package cmd \ + --bin greptime \ + --profile nightly \ + --target aarch64-unknown-linux-gnu +``` + +For Enterprise, use the user-provided executable target, for example: + +```bash +/scripts/build_binary.sh \ + --source --bin greptime-ent-cloud --profile nightly +``` + +For a user-selected profile, replace `nightly` in the helper call and resolve +the binary under `target//` (or +`target///` for cross-compilation). Reuse the +existing target file only when the interactive profile question selected reuse; +otherwise invoke the helper to rebuild it. + +Do not use a binary compiled for the host architecture in an image intended for +another architecture. Determine the binary path from the Cargo target and +profile, then copy it into the Docker context as `greptime`: + +```bash +cp /greptime +``` + +Verify it before building the image with `file /greptime`; its +reported architecture must match the requested target platform. + +Create a fresh isolated build context (never use the repository root) and use +the bundled preparation script. The helper rejects an existing Dockerfile or +binary output to prevent accidental context reuse: + +```bash +/scripts/prepare_context.sh \ + --context "$(mktemp -d)" \ + --binary \ + --dockerfile /assets/Dockerfile \ + --platform +``` + +### 3. Build or push the image + +Run the command only after the user confirms the final image reference and +whether it should be pushed. + +Use the bundled build helper rather than spelling out individual Docker or +Podman commands. It follows the existing scripts' Docker-first/Podman-fallback +behavior, uses Buildx for a non-native Docker request, and accepts exactly one +target platform per image: + +```bash +/scripts/build_image.sh \ + --context \ + --image \ + --platform \ + --mode local|push +``` + +`--mode local` builds a native image locally or uses `docker buildx --load` for +a non-native image. `--mode push` pushes the selected single-platform image. +The helper never pushes in `local` mode. + +This is intentionally a runtime image built from a precompiled binary, not a +multi-stage Dockerfile. Ubuntu 24.04 is the runtime base image only; select one +target platform separately as `linux/amd64` or `linux/arm64`. Only introduce a +multi-stage Dockerfile if the user asks to compile within Docker or the local +Rust toolchain cannot build the required target. Keep the final Ubuntu 24.04 +runtime stage and existing entrypoint unless the user asks to change runtime +behavior. + +At the final confirmation, repeat that the selected reference is a +**development/local-cluster test image**, not a release artifact. If the user +requests a release or production image, stop and direct them to the release +process instead. + +### 4. Verify the result + +For a local image, use the selected engine to inspect its architecture and +start it with `--help`: + +```bash +docker image inspect --format '{{.Os}}/{{.Architecture}}' +docker run --rm --help +``` + +For Podman, use `podman image inspect ` and +`podman run --rm --help`. For a pushed Podman image, use +`podman manifest inspect `. + +For a pushed image, inspect its remote manifest: + +```bash +docker buildx imagetools inspect +``` + +Report the final image reference, target platform, source binary path, and +whether the image was loaded locally or pushed. + +## Safety Rules + +- This skill is only for development and local-cluster testing. Never present + its image as a production, release, or officially published artifact. +- Never push by default. Local debug builds should use a local tag and load the + image unless the user explicitly requests a push. +- Do not run `docker login`, privileged QEMU setup, package installation, or + overwrite a tag without confirmation. +- Do not delete existing images, builders, binaries, or `.env` entries as part + of this workflow. +- Stop and explain if Docker's server is unavailable, Buildx cannot support the + requested platform, or the copied binary architecture does not match the + image platform. diff --git a/.agents/skills/greptimedb-development-docker-image/assets/Dockerfile b/.agents/skills/greptimedb-development-docker-image/assets/Dockerfile new file mode 100644 index 0000000000..bc74c83031 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/assets/Dockerfile @@ -0,0 +1,14 @@ +FROM ubuntu:24.04 + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + curl \ + unzip \ + mysql-client \ + wget \ + && rm -rf /var/lib/apt/lists/* + +COPY ./greptime /greptime/bin/ + +ENV PATH=/greptime/bin/:$PATH + +ENTRYPOINT ["greptime"] diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/binary_platform.py b/.agents/skills/greptimedb-development-docker-image/scripts/binary_platform.py new file mode 100644 index 0000000000..86f855a11c --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/binary_platform.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validate that a binary is an executable ELF for a requested Linux platform.""" + +import argparse +import stat +import struct +import sys +from pathlib import Path +from typing import Optional + +PLATFORM_MACHINES = {"linux/amd64": 0x3E, "linux/arm64": 0xB7} + + +def validate_platform(platform: str) -> int: + try: + return PLATFORM_MACHINES[platform] + except KeyError as error: + raise ValueError("platform must be linux/amd64 or linux/arm64") from error + + +def inspect_elf(path: Path) -> int: + executable_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + if path.is_symlink() or not path.is_file() or not (path.stat().st_mode & executable_bits): + raise ValueError("binary must be an executable regular file") + try: + with path.open("rb") as handle: + header = handle.read(64) + except OSError as error: + raise ValueError(f"cannot read binary: {error}") from error + if len(header) != 64 or header[:4] != b"\x7fELF": + raise ValueError("binary is not ELF") + if header[4] != 2 or header[5] != 1 or header[6] != 1: + raise ValueError("binary must be a 64-bit little-endian ELF") + if header[7] not in (0, 3): + raise ValueError("binary must use the System V or Linux ELF ABI") + elf_type, machine, version = struct.unpack_from(" None: + expected = validate_platform(platform) + actual = inspect_elf(path) + if actual != expected: + raise ValueError("binary architecture does not match requested platform") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", required=True, type=Path) + parser.add_argument("--platform", required=True) + args = parser.parse_args(argv) + try: + validate_binary(args.binary, args.platform) + except ValueError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/build_binary.sh b/.agents/skills/greptimedb-development-docker-image/scripts/build_binary.sh new file mode 100755 index 0000000000..605685fa7c --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/build_binary.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + printf 'Usage: %s --source PATH --bin NAME --profile PROFILE [--target RUST_TARGET] [--package PACKAGE] [--features FEATURES]\n' "$0" >&2 +} + +source_dir="" +binary_name="" +profile="" +target="" +package="" +features="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source|--bin|--profile|--target|--package|--features) + if [[ $# -lt 2 ]]; then + usage + exit 2 + fi + case "$1" in + --source) source_dir="$2" ;; + --bin) binary_name="$2" ;; + --profile) profile="$2" ;; + --target) target="$2" ;; + --package) package="$2" ;; + --features) features="$2" ;; + esac + shift 2 + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ -z "$source_dir" || -z "$binary_name" || -z "$profile" ]]; then + usage + exit 2 +fi + +if [[ ! -f "$source_dir/Cargo.toml" ]]; then + printf 'error: not a Cargo workspace: %s\n' "$source_dir" >&2 + exit 1 +fi + +command=(cargo build --locked --bin "$binary_name" --profile "$profile") +if [[ -n "$package" ]]; then + command+=(--package "$package") +fi +if [[ -n "$features" ]]; then + command+=(--features "$features") +fi +if [[ -n "$target" ]]; then + command+=(--target "$target") +fi + +printf 'building %s with profile %s\n' "$binary_name" "$profile" +( + cd "$source_dir" + exec "${command[@]}" +) diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/build_image.sh b/.agents/skills/greptimedb-development-docker-image/scripts/build_image.sh new file mode 100755 index 0000000000..3b48033467 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/build_image.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + printf 'Usage: %s --context PATH --image REFERENCE --platform PLATFORM --mode local|push [--dockerfile PATH] [--engine auto|docker|podman] [--allow-local-tag-overwrite]\n' "$0" >&2 +} + +context="" +image="" +platform="" +mode="" +dockerfile="" +engine="auto" +allow_local_tag_overwrite=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --context|--image|--platform|--mode|--dockerfile|--engine) + if [[ $# -lt 2 ]]; then + usage + exit 2 + fi + case "$1" in + --context) context="$2" ;; + --image) image="$2" ;; + --platform) platform="$2" ;; + --mode) mode="$2" ;; + --dockerfile) dockerfile="$2" ;; + --engine) engine="$2" ;; + esac + shift 2 + ;; + --allow-local-tag-overwrite) + allow_local_tag_overwrite=true + shift + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ -z "$context" || -z "$image" || -z "$platform" || -z "$mode" ]]; then + usage + exit 2 +fi + +if [[ "$mode" != "local" && "$mode" != "push" ]]; then + printf 'error: --mode must be local or push\n' >&2 + exit 2 +fi + +if [[ "$engine" != "auto" && "$engine" != "docker" && "$engine" != "podman" ]]; then + printf 'error: --engine must be auto, docker, or podman\n' >&2 + exit 2 +fi + +dockerfile="${dockerfile:-$context/Dockerfile}" +if [[ ! -f "$dockerfile" ]]; then + printf 'error: Dockerfile does not exist: %s\n' "$dockerfile" >&2 + exit 1 +fi + +if [[ "$platform" == *,* ]]; then + printf 'error: only one target platform is supported per image build\n' >&2 + exit 2 +fi + +case "$platform" in + linux/amd64|linux/arm64) ;; + *) + printf 'error: unsupported platform: %s\n' "$platform" >&2 + exit 2 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "$script_dir/binary_platform.py" --binary "$context/greptime" --platform "$platform" + +check_collision() { + local runtime="$1" + if [[ "$allow_local_tag_overwrite" == true ]]; then + return 0 + fi + if [[ "$runtime" == docker ]] && docker image inspect "$image" >/dev/null 2>&1; then + printf 'error: local image tag exists: %s (pass --allow-local-tag-overwrite to replace it)\n' "$image" >&2 + exit 3 + fi + if [[ "$runtime" == podman ]] && podman image exists "$image"; then + printf 'error: local image tag exists: %s (pass --allow-local-tag-overwrite to replace it)\n' "$image" >&2 + exit 3 + fi +} + +if [[ "$engine" != podman ]] && command -v docker >/dev/null 2>&1 && server_platform="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)"; then + if [[ "$platform" == "$server_platform" ]]; then + check_collision docker + docker build --platform "$platform" -f "$dockerfile" -t "$image" "$context" + if [[ "$mode" == "push" ]]; then + exec docker push "$image" + fi + exit 0 + fi + + if [[ "$mode" == "push" ]]; then + exec docker buildx build --platform "$platform" --push -f "$dockerfile" -t "$image" "$context" + fi + check_collision docker + exec docker buildx build --platform "$platform" --load -f "$dockerfile" -t "$image" "$context" +fi + +if [[ "$engine" != docker ]] && command -v podman >/dev/null 2>&1; then + native_platform="$(podman info --format '{{.Host.Os}}/{{.Host.Arch}}' 2>/dev/null || true)" + case "$native_platform" in + linux/amd64|linux/arm64) ;; + *) + printf 'error: Podman is unavailable or has unsupported platform: %s\n' "$native_platform" >&2 + exit 1 + ;; + esac + if [[ "$platform" != "$native_platform" ]]; then + printf 'error: Docker Buildx is required for non-native platform %s\n' "$platform" >&2 + exit 1 + fi + check_collision podman + podman build -f "$dockerfile" -t "$image" "$context" + if [[ "$mode" == "push" ]]; then + exec podman push "$image" + fi + exit 0 +fi + +printf 'error: Docker or Podman is required\n' >&2 +exit 1 diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/collect_context.py b/.agents/skills/greptimedb-development-docker-image/scripts/collect_context.py new file mode 100755 index 0000000000..214d56ee78 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/collect_context.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Collect read-only build context for a GreptimeDB development image.""" + +import argparse +import json +import os +import platform +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional, cast + +from next_image_tag import increment_tag +from binary_platform import PLATFORM_MACHINES, inspect_elf +from image_config import image_reference, parse_env_file, validate_values + + +ENV_KEYS = ("IMAGE_REGISTRY", "IMAGE_REPOSITORY", "IMAGE_TAG") +PROFILE_DIRECTORIES = {"dev": "debug", "test": "debug", "bench": "release"} +PLATFORM_ARCHITECTURES = { + "x86_64": "linux/amd64", + "amd64": "linux/amd64", + "aarch64": "linux/arm64", + "arm64": "linux/arm64", +} + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, type=Path) + parser.add_argument("--bin", dest="binary", default="greptime") + parser.add_argument("--profile", default="nightly") + parser.add_argument("--target", help="Rust target triple for cross-compilation") + parser.add_argument("--package", help="Cargo package containing the selected binary") + parser.add_argument("--platform", choices=("linux/amd64", "linux/arm64")) + parser.add_argument("--engine", choices=("auto", "docker", "podman"), default="auto") + parser.add_argument("--registry") + parser.add_argument("--repository") + parser.add_argument("--tag") + parser.add_argument( + "--check-registry-tag", + action="store_true", + help="inspect the configured image tag with Docker without modifying it", + ) + return parser.parse_args() + + +def native_platform(architecture: str) -> Optional[str]: + return PLATFORM_ARCHITECTURES.get(architecture.lower()) + + +def parse_env(env_path: Path) -> dict[str, object]: + values = parse_env_file(env_path) + missing = [key for key in ("IMAGE_REPOSITORY", "IMAGE_TAG") if not values.get(key)] + try: + if not missing: + validate_values(values) + error = None + except ValueError as exc: + error = str(exc) + return {"path": str(env_path), "exists": env_path.is_file(), "values": values, "missing": missing, "error": error} + + +def cargo_metadata(source: Path) -> dict[str, Any]: + command = ["cargo", "metadata", "--locked", "--format-version=1", "--no-deps"] + try: + result = subprocess.run(command, cwd=source, text=True, capture_output=True, check=True) + except FileNotFoundError as error: + raise RuntimeError("cargo is not installed or is not on PATH") from error + except subprocess.CalledProcessError as error: + detail = error.stderr.strip() or error.stdout.strip() + raise RuntimeError(f"cargo metadata failed: {detail}") from error + return json.loads(result.stdout) + + +def binary_targets(metadata: dict[str, Any]) -> list[dict[str, Any]]: + targets = [] + for package in metadata["packages"]: + for target in package["targets"]: + if "bin" in target["kind"]: + targets.append({ + "package": package["name"], + "name": target["name"], + "path": target["src_path"], + "required_features": target.get("required-features", []), + }) + return sorted(targets, key=lambda target: (target["name"], target["package"])) + + +def expected_binary(target_dir: Path, binary: str, profile: str, rust_target: Optional[str]) -> Path: + profile_directory = PROFILE_DIRECTORIES.get(profile, profile) + output_dir = target_dir / rust_target if rust_target else target_dir + return output_dir / profile_directory / binary + + +def engine_status(engine: str) -> dict[str, object]: + if engine == "docker": + command = ["docker", "version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"] + else: + command = ["podman", "info", "--format", "{{.Host.Os}}/{{.Host.Arch}}"] + try: + result = subprocess.run(command, text=True, capture_output=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError): + return {"available": False, "server_platform": None} + return {"available": True, "server_platform": result.stdout.strip()} + + +def registry_tag_status(values: dict[str, str], enabled: bool, engine: str, source: str) -> dict[str, object]: + if not enabled: + return {"checked": False, "status": "not_checked", "candidate_tag": None} + + try: + reference = image_reference(values) + except ValueError: + return {"checked": False, "status": "not_configured", "candidate_tag": None, "configuration_source": source} + command = ["docker", "buildx", "imagetools", "inspect", reference] if engine == "docker" else ["podman", "manifest", "inspect", reference] + try: + subprocess.run(command, text=True, capture_output=True, check=True) + except FileNotFoundError: + return {"checked": True, "status": "unknown", "candidate_tag": None, "reference": reference, "engine": engine, "configuration_source": source} + except subprocess.CalledProcessError: + return {"checked": True, "status": "unknown", "candidate_tag": None, "reference": reference, "engine": engine, "configuration_source": source} + + try: + candidate_tag = increment_tag(values["IMAGE_TAG"]) + except ValueError: + candidate_tag = None + return {"checked": True, "status": "exists", "candidate_tag": candidate_tag, "reference": reference, "engine": engine, "configuration_source": source} + + +def collect_report(args: argparse.Namespace) -> dict[str, object]: + source = args.source.expanduser().resolve() + if not (source / "Cargo.toml").is_file(): + raise RuntimeError(f"not a Cargo workspace: {source}") + + metadata = cargo_metadata(source) + targets = binary_targets(metadata) + target_dir = Path(metadata["target_directory"]) + expected = expected_binary(target_dir, args.binary, args.profile, args.target) + host_architecture = platform.machine() + selected_target = next((target for target in targets if target["name"] == args.binary), None) + environment = parse_env(source / ".env") + supplied_values = (args.registry, args.repository, args.tag) + if any(value is not None for value in supplied_values) and not all(value is not None for value in supplied_values): + raise RuntimeError("--registry, --repository, and --tag must be supplied together") + values: dict[str, str] = {"IMAGE_REGISTRY": args.registry or "", "IMAGE_REPOSITORY": args.repository or "", "IMAGE_TAG": args.tag or ""} if all(value is not None for value in supplied_values) else cast(dict[str, str], environment["values"]) + configuration_source = "arguments" if all(value is not None for value in supplied_values) else "environment" + docker = engine_status("docker") + podman = engine_status("podman") + engine = args.engine if args.engine != "auto" else "docker" if docker["available"] else "podman" + binary_details: dict[str, object] = {"platform": None, "error": None} + if expected.is_file(): + try: + machine = inspect_elf(expected) + binary_details["platform"] = next( + (name for name, expected_machine in PLATFORM_MACHINES.items() if expected_machine == machine), + None, + ) + if binary_details["platform"] is None: + binary_details["error"] = f"binary has unsupported ELF machine: {machine}" + except ValueError as exc: + binary_details["error"] = str(exc) + return { + "host": {"os": platform.system(), "arch": host_architecture, "native_platform": native_platform(host_architecture)}, + "docker": docker, + "podman": podman, + "environment": environment, + "registry_tag": registry_tag_status(values, args.check_registry_tag, engine, configuration_source), + "cargo": {"workspace_root": metadata["workspace_root"], "target_dir": str(target_dir), "binaries": targets}, + "selected_binary": {"name": args.binary, "package": args.package, "cargo_target_exists": selected_target is not None, "target": selected_target}, + "expected_binary": {"profile": args.profile, "rust_target": args.target, "path": str(expected), "exists": expected.is_file(), "executable": os.access(expected, os.X_OK), "platform": binary_details["platform"], "requested_platform": args.platform, "platform_matches": binary_details["platform"] == args.platform if args.platform and binary_details["platform"] else None, "validation_error": binary_details["error"], "mtime": datetime.fromtimestamp(expected.stat().st_mtime, timezone.utc).isoformat() if expected.exists() else None, "freshness": "unverified"}, + } + + +def main() -> int: + try: + print(json.dumps(collect_report(parse_arguments()), indent=2, sort_keys=True)) + except RuntimeError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/image_config.py b/.agents/skills/greptimedb-development-docker-image/scripts/image_config.py new file mode 100644 index 0000000000..cb3260d8c8 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/image_config.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bounded parsing and safe updating of the image settings in a .env file.""" + +import os +import re +import tempfile +from pathlib import Path +from typing import Mapping + +MANAGED_KEYS = ("IMAGE_REGISTRY", "IMAGE_REPOSITORY", "IMAGE_TAG") +_REGISTRY = re.compile(r"[a-z0-9][a-z0-9.-]*(?::[0-9]+)?(?:/[a-z0-9][a-z0-9._-]*)*") +_REPOSITORY = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*") +_TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}") + + +def _value(text: str) -> str: + text = text.strip() + if not text: + return "" + quote = text[0] if text[0] in "'\"" else None + if quote: + end = text.find(quote, 1) + if end < 0 or text[end + 1 :].strip() and not text[end + 1 :].lstrip().startswith("#"): + raise ValueError("unmatched quote or trailing value syntax") + return text[1:end] + # A comment is inline only when introduced by whitespace. This keeps + # valid values such as an image tag containing '#'. + match = re.search(r"\s+#", text) + if match: + text = text[: match.start()].rstrip() + if any(character in text for character in "'\";$`"): + raise ValueError("unsupported shell syntax in .env value") + return text + + +def parse_env(text: str) -> dict[str, str]: + """Parse the three managed keys, without validating their values.""" + lines = text.splitlines() + result: dict[str, str] = {} + for number, original in enumerate(lines, 1): + line = original.strip() + if not line or line.startswith("#"): + continue + export = re.match(r"export[ \t]+", line) + if export: + line = line[export.end() :].lstrip() + key, separator, raw = line.partition("=") + if not separator: + continue + key = key.strip() + if key in MANAGED_KEYS: + try: + result[key] = _value(raw) + except ValueError as error: + raise ValueError(f"line {number}: {error}") from error + return result + + +def parse_env_file(path: Path) -> dict[str, str]: + return parse_env(path.read_text() if path.exists() else "") + + +def normalize_values(values: Mapping[str, str]) -> dict[str, str]: + return { + "IMAGE_REGISTRY": values.get("IMAGE_REGISTRY", ""), + "IMAGE_REPOSITORY": values.get("IMAGE_REPOSITORY", ""), + "IMAGE_TAG": values.get("IMAGE_TAG", ""), + } + + +def validate_values(values: Mapping[str, str]) -> None: + values = normalize_values(values) + missing = [key for key in ("IMAGE_REPOSITORY", "IMAGE_TAG") if not values.get(key)] + if missing: + raise ValueError("missing managed values: " + ", ".join(missing)) + registry = values.get("IMAGE_REGISTRY", "") + if registry and ( + not _REGISTRY.fullmatch(registry) + ): + raise ValueError("invalid registry") + if registry: + host = registry.split("/", 1)[0] + if host != "localhost" and "." not in host and ":" not in host: + raise ValueError("ambiguous registry; use an FQDN, explicit port, or localhost") + if not _REPOSITORY.fullmatch(values["IMAGE_REPOSITORY"]): + raise ValueError("invalid repository") + if not _TAG.fullmatch(values["IMAGE_TAG"]): + raise ValueError("invalid tag") + if any(any(character.isspace() for character in value) for value in values.values()): + raise ValueError("managed values cannot contain whitespace") + + +def image_reference(values: Mapping[str, str]) -> str: + values = normalize_values(values) + validate_values(values) + prefix = values.get("IMAGE_REGISTRY", "") + repository = values["IMAGE_REPOSITORY"] + return f"{prefix}/{repository}:{values['IMAGE_TAG']}" if prefix else f"{repository}:{values['IMAGE_TAG']}" + + +def update_env(path: Path, values: Mapping[str, str]) -> bool: + """Atomically update managed entries; return whether the file changed.""" + values = normalize_values(values) + validate_values(values) + if path.is_symlink(): + raise ValueError("refusing to update symlink .env") + old = path.read_text() if path.exists() else "" + parse_env(old) # detect malformed managed syntax before changing anything + lines = old.splitlines() + output: list[str] = [] + written: set[str] = set() + for line in lines: + stripped = line.strip() + export = re.match(r"export[ \t]+", stripped) + candidate = stripped[export.end() :].lstrip() if export else stripped + key = candidate.split("=", 1)[0].strip() if "=" in candidate else "" + if key in MANAGED_KEYS: + if key in written: + continue + output.append(f"{key}={values[key]}") + written.add(key) + else: + output.append(line) + output.extend(f"{key}={values.get(key, '')}" for key in MANAGED_KEYS if key not in written) + new = "\n".join(output) + "\n" + if old == new: + return False + path.parent.mkdir(parents=True, exist_ok=True) + mode = os.stat(path).st_mode & 0o777 if path.exists() else 0o600 + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, "w") as handle: + handle.write(new) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return True diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/next_image_tag.py b/.agents/skills/greptimedb-development-docker-image/scripts/next_image_tag.py new file mode 100755 index 0000000000..ad261ebd65 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/next_image_tag.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Print a tag with its final numeric component incremented.""" + +import argparse +import re +import sys + + +def increment_tag(tag: str) -> str: + match = re.search(r"(\d+)$", tag) + if match is None: + raise ValueError("tag must end with a numeric version component") + + number = match.group(1) + return f"{tag[:match.start()]}{int(number) + 1:0{len(number)}d}" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Increment the final numeric component of a Docker image tag." + ) + parser.add_argument("--tag", required=True, help="existing Docker image tag") + args = parser.parse_args() + + try: + print(increment_tag(args.tag)) + except ValueError as error: + print(f"error: {error}: {args.tag!r}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/prepare_context.sh b/.agents/skills/greptimedb-development-docker-image/scripts/prepare_context.sh new file mode 100755 index 0000000000..f0ea66fdf3 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/prepare_context.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + printf 'Usage: %s --context PATH --binary PATH --dockerfile PATH --platform PLATFORM\n' "$0" >&2 +} + +context="" +binary="" +dockerfile="" +platform="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --context) + context="$2" + shift 2 + ;; + --binary) + binary="$2" + shift 2 + ;; + --dockerfile) + dockerfile="$2" + shift 2 + ;; + --platform) + platform="$2" + shift 2 + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ -z "$context" || -z "$binary" || -z "$dockerfile" || -z "$platform" ]]; then + usage + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "$script_dir/binary_platform.py" --binary "$binary" --platform "$platform" + +if [[ ! -f "$dockerfile" ]]; then + printf 'error: Dockerfile template does not exist: %s\n' "$dockerfile" >&2 + exit 1 +fi + +if [[ -e "$context" && ( -L "$context" || -n "$(ls -A "$context")" ) ]]; then + printf 'error: build context must be a new or empty directory: %s\n' "$context" >&2 + exit 1 +fi +mkdir -p "$context" +if [[ -e "$context/greptime" || -e "$context/Dockerfile" || -L "$context/greptime" || -L "$context/Dockerfile" ]]; then + printf 'error: build context already contains output files: %s\n' "$context" >&2 + exit 1 +fi +cp "$binary" "$context/greptime" +cp "$dockerfile" "$context/Dockerfile" +printf 'created Dockerfile: %s\n' "$context/Dockerfile" + +file "$context/greptime" diff --git a/.agents/skills/greptimedb-development-docker-image/scripts/update_image_env.py b/.agents/skills/greptimedb-development-docker-image/scripts/update_image_env.py new file mode 100755 index 0000000000..9bf8cbeea0 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/scripts/update_image_env.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Create or update non-secret GreptimeDB image settings in a .env file.""" + +import argparse +import sys +from pathlib import Path + +from image_config import image_reference, normalize_values, update_env, validate_values + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env", type=Path) + parser.add_argument("--registry", required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--validate-only", action="store_true") + args = parser.parse_args() + + try: + values = normalize_values({"IMAGE_REGISTRY": args.registry, "IMAGE_REPOSITORY": args.repository, "IMAGE_TAG": args.tag}) + validate_values(values) + except ValueError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + if args.validate_only: + print(image_reference(values)) + return 0 + if args.env is None: + parser.error("--env is required unless --validate-only is set") + if update_env(args.env, values): + print(f"updated {args.env}") + else: + print(f"unchanged {args.env}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/greptimedb-development-docker-image/tests/test_binary_platform.py b/.agents/skills/greptimedb-development-docker-image/tests/test_binary_platform.py new file mode 100644 index 0000000000..e89bafa069 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/tests/test_binary_platform.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +import struct +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +binary_platform = importlib.import_module("binary_platform") + + +def elf(machine: int, *, bits: int = 2, endian: int = 1, abi: int = 3, elf_type: int = 3) -> bytes: + header = bytearray(64) + header[:4] = b"\x7fELF" + header[4:8] = bytes((bits, endian, 1, abi)) + struct.pack_into("H", header, 18, machine) + struct.pack_into(" Path: + path = Path(self.directory.name) / "greptime" + path.write_bytes(content) + path.chmod(0o755) + return path + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + + def tearDown(self): + self.directory.cleanup() + + def test_matching_architectures(self): + binary_platform.validate_binary(self.make_binary(elf(0x3E)), "linux/amd64") + binary_platform.validate_binary(self.make_binary(elf(0xB7)), "linux/arm64") + + def test_rejects_bad_executable_elf_and_platform(self): + path = self.make_binary(elf(0x3E)) + path.chmod(0o644) + with self.assertRaises(ValueError): + binary_platform.validate_binary(path, "linux/amd64") + path = self.make_binary(b"not an elf") + with self.assertRaises(ValueError): + binary_platform.validate_binary(path, "linux/amd64") + path = self.make_binary(elf(0x3E, bits=1)) + with self.assertRaises(ValueError): + binary_platform.validate_binary(path, "linux/amd64") + with self.assertRaises(ValueError): + binary_platform.validate_binary(self.make_binary(elf(0x3E, abi=9)), "linux/amd64") + with self.assertRaises(ValueError): + binary_platform.validate_binary(self.make_binary(elf(0x3E, elf_type=1)), "linux/amd64") + with self.assertRaises(ValueError): + binary_platform.validate_binary(self.make_binary(b"\x7fELF"), "linux/amd64") + with self.assertRaises(ValueError): + binary_platform.validate_binary(self.make_binary(elf(0x3E)), "darwin/amd64") + + def test_rejects_wrong_architecture_and_symlink(self): + with self.assertRaises(ValueError): + binary_platform.validate_binary(self.make_binary(elf(0x3E)), "linux/arm64") + target = self.make_binary(elf(0x3E)) + link = Path(self.directory.name) / "link" + link.symlink_to(target) + with self.assertRaises(ValueError): + binary_platform.validate_binary(link, "linux/amd64") + + def test_cli_success_and_failures(self): + path = self.make_binary(elf(0x3E)) + assert binary_platform.__file__ is not None + script = Path(binary_platform.__file__) + ok = subprocess.run([sys.executable, str(script), "--binary", str(path), "--platform", "linux/amd64"], capture_output=True, text=True) + self.assertEqual(ok.returncode, 0, ok.stderr) + bad = subprocess.run([sys.executable, str(script), "--binary", str(path), "--platform", "linux/arm64"], capture_output=True, text=True) + self.assertEqual(bad.returncode, 2) + missing = subprocess.run([sys.executable, str(script), "--binary", str(path) + ".missing", "--platform", "linux/amd64"], capture_output=True, text=True) + self.assertEqual(missing.returncode, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/greptimedb-development-docker-image/tests/test_image_config.py b/.agents/skills/greptimedb-development-docker-image/tests/test_image_config.py new file mode 100644 index 0000000000..95dc913ea5 --- /dev/null +++ b/.agents/skills/greptimedb-development-docker-image/tests/test_image_config.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# Copyright 2023 Greptime Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +import stat +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +image_config = importlib.import_module("image_config") + + +VALUES = {"IMAGE_REGISTRY": "registry.example.com/team", "IMAGE_REPOSITORY": "greptime-dev", "IMAGE_TAG": "dev-001"} + + +class ImageConfigTest(unittest.TestCase): + def test_parse_supported_bounded_dotenv(self): + text = """\n# comment\n export IMAGE_REGISTRY = \"registry.example.com/team\" # note\nIMAGE_REPOSITORY='greptime-dev'\nIMAGE_TAG=dev-001 # safe comment\nOTHER=$HOME\n""" + self.assertEqual(image_config.parse_env(text), VALUES) + + def test_parser_does_not_validate_values(self): + self.assertEqual(image_config.parse_env("IMAGE_TAG=not valid\n"), {"IMAGE_TAG": "not valid"}) + + def test_parser_rejects_unmatched_and_unsupported_syntax(self): + with self.assertRaises(ValueError): + image_config.parse_env('IMAGE_TAG="unterminated\n') + with self.assertRaises(ValueError): + image_config.parse_env("IMAGE_TAG=$(date)\n") + + def test_validation_and_reference(self): + image_config.validate_values(VALUES) + self.assertEqual(image_config.image_reference(VALUES), "registry.example.com/team/greptime-dev:dev-001") + local = dict(VALUES, IMAGE_REGISTRY="") + self.assertEqual(image_config.image_reference(local), "greptime-dev:dev-001") + image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="localhost")) + image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="example.com")) + with self.assertRaises(ValueError): + image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="registry/team")) + + def test_atomic_update_preserves_mode_and_canonicalizes_duplicates(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text("# keep\nIMAGE_TAG=old\nIMAGE_TAG=duplicate\nOTHER=value\n") + path.chmod(0o640) + self.assertTrue(image_config.update_env(path, VALUES)) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640) + self.assertEqual(path.read_text(), "# keep\nIMAGE_TAG=dev-001\nOTHER=value\nIMAGE_REGISTRY=registry.example.com/team\nIMAGE_REPOSITORY=greptime-dev\n") + self.assertFalse(image_config.update_env(path, VALUES)) + + def test_update_rejects_symlink(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / "target" + target.write_text("IMAGE_TAG=old\n") + link = Path(directory) / ".env" + link.symlink_to(target) + with self.assertRaises(ValueError): + image_config.update_env(link, VALUES) + self.assertEqual(target.read_text(), "IMAGE_TAG=old\n") + + def test_update_clears_omitted_registry_and_uses_private_new_file_mode(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text("IMAGE_REGISTRY=registry.example.com\nIMAGE_REPOSITORY=old\nIMAGE_TAG=old\n") + values = {"IMAGE_REPOSITORY": "greptime-dev", "IMAGE_TAG": "dev-001"} + image_config.update_env(path, values) + self.assertIn("IMAGE_REGISTRY=\n", path.read_text()) + new_path = Path(directory) / "new.env" + image_config.update_env(new_path, VALUES) + self.assertEqual(stat.S_IMODE(new_path.stat().st_mode), 0o600) + + def test_failed_replace_keeps_original_and_removes_temporary_file(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / ".env" + path.write_text("OTHER=value\n") + with mock.patch("image_config.os.replace", side_effect=OSError("no space")): + with self.assertRaises(OSError): + image_config.update_env(path, VALUES) + self.assertEqual(path.read_text(), "OTHER=value\n") + self.assertEqual(list(Path(directory).glob("..env.*")), []) + + +if __name__ == "__main__": + unittest.main()