diff --git a/.dockerignore b/.dockerignore index 288d980b3f235..ecb881d8d3d4d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,5 @@ * !distribution !docker/src/main/DockerCompose/start-1c1d.sh +!docker/src/main/ainode-build-data/ +!docker/src/main/ainode-entrypoint.sh \ No newline at end of file diff --git a/docker/ReadMe.md b/docker/ReadMe.md index 1574099c3749f..f674364626b78 100644 --- a/docker/ReadMe.md +++ b/docker/ReadMe.md @@ -53,6 +53,9 @@ e.g. ./do-docker-build.sh -t standalone -v 1.0.0 # for ainode, start from 2.0.5 ./do-docker-build.sh -t ainode -v 2.0.5-SNAPSHOT +# for ainode, start from 2.0.8 +cd src/main +./build-ainode.sh -v 2.0.8-SNAPSHOT -d /data/ainode ``` Notice: Make directory of src/main/target and put the zip file downloading from the official download page. @@ -91,6 +94,23 @@ Please download `docker-compose-ainode.yml` in `docker/src/main/DockerCompose` f docker compose -f docker-compose-ainode.yml up -d ``` +Start from v2.0.7, run +```shell +docker run -d \ + --name iotdb-ainode \ + --network host \ + -p 10810:10810 \ + -p 8080:8080 \ + -e AIN_SEED_CONFIG_NODE=127.0.0.1:10710 \ + -e AIN_RPC_ADDRESS=127.0.0.1 \ + -e AIN_RPC_PORT=10810 \ + -e AIN_CLUSTER_INGRESS_ADDRESS=127.0.0.1 \ + -e AIN_CLUSTER_INGRESS_PORT=6667 \ + -e AIN_CLUSTER_INGRESS_USERNAME=root \ + -e AIN_CLUSTER_INGRESS_PASSWORD=root \ + apache/iotdb:2.0.7-SNAPSHOT-ainode +``` + ## Quick start We provide `docker-compose-cluster-1c1d1a.yml` in `docker/src/main/DockerCompose`. Downloading this yaml file, both standalone and ainode docker first. Subsequently, you can easily obtain a IoTDB cluster, which consists of a ConfigNode, a DataNode and a AINode, in your local machine. diff --git a/docker/src/main/DockerCompose/docker-compose-ainode.yml b/docker/src/main/DockerCompose/docker-compose-ainode.yml index e393d9ccffec5..c61567aeca257 100644 --- a/docker/src/main/DockerCompose/docker-compose-ainode.yml +++ b/docker/src/main/DockerCompose/docker-compose-ainode.yml @@ -40,9 +40,12 @@ services: - ain_cluster_ingress_username=root - ain_cluster_ingress_password=root - ain_cluster_ingress_time_zone=UTC+8 + ports: + - "10810:10810" volumes: - ainode-data:/ainode/data - ./logs/ainode:/ainode/logs + restart: unless-stopped # - ./lib/ainode:/ainode/lib # Uncomment for rolling upgrade # Note: Some environments set an extremely high container nofile limit (~2^30 = 1073741824). # This can make the startup step "Checking whether the ports are already occupied..." appear to hang (lsof slow). diff --git a/docker/src/main/Dockerfile-1.0.0-ainode b/docker/src/main/Dockerfile-1.0.0-ainode deleted file mode 100644 index 7e315c6493927..0000000000000 --- a/docker/src/main/Dockerfile-1.0.0-ainode +++ /dev/null @@ -1,66 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you 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. -# -FROM python:3.12-slim-bullseye -ARG version=2.0.5-SNAPSHOT -ARG target=apache-iotdb-${version}-ainode-bin - -# replace deb source when necessary -# RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \ -# sed -i 's|security.debian.org/debian-security|mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list - -# replace pip source when necessary -# RUN pip config set global.index-url https://mirrors.ustc.edu.cn/pypi/web/simple - -RUN apt update \ - && apt install lsof dos2unix procps unzip dumb-init wget inetutils-ping libopenblas-dev liblapack-dev gfortran gcc g++ -y \ - && apt autoremove -y \ - && apt purge --auto-remove -y \ - && apt clean -y - -COPY target/${target}.zip / -RUN cd / && unzip ${target}.zip \ - && rm ${target}.zip \ - && mv ${target} ainode - -ENV IOTDB_AINODE_HOME=/ainode VERSION=${version} -WORKDIR ${IOTDB_AINODE_HOME}/sbin - -COPY DockerCompose/replace-conf-from-env.sh . -COPY DockerCompose/entrypoint.sh . - -RUN chmod +x *.sh && dos2unix *.sh \ - && dos2unix ${IOTDB_AINODE_HOME}/conf/*.sh - -# use huggingface mirrors when necessary -ARG hf_web=huggingface.co -# ARG hf_web=hf-mirror.com -RUN mkdir -p ${IOTDB_AINODE_HOME}/data/ainode/models/weights/sundial && \ - mkdir -p ${IOTDB_AINODE_HOME}/data/ainode/models/weights/timer_xl -RUN wget -O ${IOTDB_AINODE_HOME}/data/ainode/models/weights/sundial/config.json https://${hf_web}/thuml/sundial-base-128m/resolve/main/config.json && \ - wget -O ${IOTDB_AINODE_HOME}/data/ainode/models/weights/sundial/model.safetensors https://${hf_web}/thuml/sundial-base-128m/resolve/main/model.safetensors -RUN wget -O ${IOTDB_AINODE_HOME}/data/ainode/models/weights/timer_xl/config.json https://${hf_web}/thuml/timer-base-84m/resolve/main/config.json && \ - wget -O ${IOTDB_AINODE_HOME}/data/ainode/models/weights/timer_xl/model.safetensors https://${hf_web}/thuml/timer-base-84m/resolve/main/model.safetensors - - -ENV PATH="${IOTDB_AINODE_HOME}/sbin/:${IOTDB_AINODE_HOME}/tools/:${PATH}" -RUN bash start-ainode.sh || true -RUN rm -r ${IOTDB_AINODE_HOME}/logs/* - -ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD ["bash", "-c", "entrypoint.sh ainode"] diff --git a/docker/src/main/Dockerfile-2.0.x-ainode b/docker/src/main/Dockerfile-2.0.x-ainode new file mode 100644 index 0000000000000..57965f6014dc9 --- /dev/null +++ b/docker/src/main/Dockerfile-2.0.x-ainode @@ -0,0 +1,88 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +FROM python:3.11-slim-bullseye + +# Build argument: Version number (required) +ARG VERSION + +# Fail build if VERSION is not provided +RUN if [ -z "$VERSION" ]; then echo "ERROR: VERSION build argument is required" && exit 1; fi + +# Set environment variables +ENV IOTDB_HOME=/ainode \ + PATH=$PATH:/ainode/sbin \ + DEBIAN_FRONTEND=noninteractive + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + unzip \ + procps \ + netcat-openbsd \ + curl \ + vim \ + tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Set working directory +WORKDIR /tmp + +# Copy and extract the distribution package +# Note: Build context is project root (../../.. relative to this Dockerfile) +COPY distribution/target/apache-iotdb-${VERSION}-ainode-bin.zip /tmp/ + +# Extract and rename directory +RUN unzip -q apache-iotdb-${VERSION}-ainode-bin.zip \ + && mv apache-iotdb-${VERSION}-ainode-bin /ainode \ + && rm -f apache-iotdb-${VERSION}-ainode-bin.zip \ + && mkdir -p /ainode/logs /ainode/data/ainode + +# Copy data directory from build server to image +# The build script copies /data/ainode to docker/src/main/ainode-build-data/ before build +# Note: The trailing slash is important - it copies contents not the directory itself +COPY docker/src/main/ainode-build-data/ /ainode/data/ainode/ + + +# Set directory permissions +RUN chmod -R 755 /ainode/sbin \ + && chmod +x /ainode/sbin/*.sh \ + && chmod -R 777 /ainode/data /ainode/logs + +# Health check configuration +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Expose ports +# 10810: AINode RPC port (ain_inference_rpc_port) +# 8080: Health check / management port +EXPOSE 10810 8080 + +# Copy entrypoint script (relative to project root) +COPY docker/src/main/ainode-entrypoint.sh /ainode/sbin/ +RUN chmod +x /ainode/sbin/ainode-entrypoint.sh + +# Set working directory +WORKDIR /ainode + +# Use entrypoint script as startup command +ENTRYPOINT ["/ainode/sbin/ainode-entrypoint.sh"] + +# Default command (can be overridden) +CMD ["start"] \ No newline at end of file diff --git a/docker/src/main/ainode-entrypoint.sh b/docker/src/main/ainode-entrypoint.sh new file mode 100644 index 0000000000000..bf60a6ce76009 --- /dev/null +++ b/docker/src/main/ainode-entrypoint.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# +# AINode Docker Entrypoint Script +# Supports configuration via environment variables for iotdb-ainode.properties +# + +set -e + +IOTDB_HOME="/ainode" +CONF_FILE="${IOTDB_HOME}/conf/iotdb-ainode.properties" + +# Logging function +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +# Initialize configuration file +init_config() { + if [ ! -f "${CONF_FILE}" ]; then + log "ERROR: Configuration file not found: ${CONF_FILE}" + exit 1 + fi + + log "Initializing AINode configuration..." + + # Backup original configuration + if [ ! -f "${CONF_FILE}.original" ]; then + cp "${CONF_FILE}" "${CONF_FILE}.original" + fi + + # Update configuration based on environment variables + # Cluster configuration + update_config "cluster_name" "${CLUSTER_NAME:-defaultCluster}" + update_config "ain_seed_config_node" "${AIN_SEED_CONFIG_NODE:-127.0.0.1:10710}" + + # AINode service configuration + update_config "ain_rpc_address" "${AIN_RPC_ADDRESS:-0.0.0.0}" + update_config "ain_rpc_port" "${AIN_RPC_PORT:-10810}" + + # DataNode connection configuration + update_config "ain_cluster_ingress_address" "${AIN_CLUSTER_INGRESS_ADDRESS:-127.0.0.1}" + update_config "ain_cluster_ingress_port" "${AIN_CLUSTER_INGRESS_PORT:-6667}" + update_config "ain_cluster_ingress_username" "${AIN_CLUSTER_INGRESS_USERNAME:-root}" + update_config "ain_cluster_ingress_password" "${AIN_CLUSTER_INGRESS_PASSWORD:-root}" + + # Storage paths configuration + update_config "ain_system_dir" "${AIN_SYSTEM_DIR:-data/ainode/system}" + update_config "ain_models_dir" "${AIN_MODELS_DIR:-data/ainode/models}" + + # Thrift compression configuration + update_config "ain_thrift_compression_enabled" "${AIN_THRIFT_COMPRESSION_ENABLED:-0}" + + log "Configuration initialized successfully" +} + +# Update configuration item in properties file +update_config() { + local key=$1 + local value=$2 + + if grep -q "^${key}=" "${CONF_FILE}"; then + # Update existing configuration + sed -i "s|^${key}=.*|${key}=${value}|g" "${CONF_FILE}" + else + # Append new configuration + echo "${key}=${value}" >> "${CONF_FILE}" + fi +} + +# Start AINode +start_ainode() { + log "Starting AINode..." + + # Check if already running + if [ -f "${IOTDB_HOME}/data/ainode.pid" ]; then + local pid=$(cat "${IOTDB_HOME}/data/ainode.pid") + if ps -p ${pid} > /dev/null 2>&1; then + log "AINode is already running with PID ${pid}" + return 0 + fi + fi + + # Use exec to replace current process and ensure proper signal handling + exec ${IOTDB_HOME}/sbin/start-ainode.sh +} + +# Stop AINode +stop_ainode() { + log "Stopping AINode..." + if [ -f "${IOTDB_HOME}/sbin/stop-ainode.sh" ]; then + ${IOTDB_HOME}/sbin/stop-ainode.sh + else + log "Stop script not found" + fi +} + +# Handle signals for graceful shutdown +trap stop_ainode SIGTERM SIGINT + +# Main logic +case "${1:-start}" in + start) + init_config + start_ainode + ;; + stop) + stop_ainode + ;; + restart) + stop_ainode + sleep 10 + init_config + start_ainode + ;; + status) + if [ -f "${IOTDB_HOME}/data/ainode.pid" ]; then + cat "${IOTDB_HOME}/data/ainode.pid" + else + echo "AINode is not running" + exit 1 + fi + ;; + config) + # Only generate configuration without starting (for debugging) + init_config + cat "${CONF_FILE}" + ;; + *) + # Execute other commands directly + exec "$@" + ;; +esac \ No newline at end of file diff --git a/docker/src/main/build-ainode.sh b/docker/src/main/build-ainode.sh new file mode 100644 index 0000000000000..44a5439c54c13 --- /dev/null +++ b/docker/src/main/build-ainode.sh @@ -0,0 +1,270 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# +# AINode Docker Image Build Script +# Run this script from docker/src/main directory +# Usage: ./build.sh -v [options] +# + +set -e + +# Get script directory (should be docker/src/main) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Project root is 3 levels up from docker/src/main +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +# Default configuration +VERSION="" +IMAGE_NAME="apache/iotdb" +IMAGE_TAG_SUFFIX="ainode" +PUSH_IMAGE=false +NO_CACHE=false +DATA_DIR="/data/ainode" +REGISTRY_PREFIX="" + +# Usage information +usage() { + cat << EOF +Usage: $0 -v [options] + +Required: + -v, --version Specify IoTDB version (e.g., 2.0.8) + +Options: + -p, --push Push image to registry after build + -n, --no-cache Build without Docker cache + -t, --tag Custom image tag (default: -ainode) + -d, --data-dir Data directory path (default: /data/ainode) + -r, --registry Registry prefix (e.g., registry.example.com) + -h, --help Show this help message + +Examples: + # Build version 2.0.8 + $0 -v 2.0.8 + + # Build and push + $0 -v 2.0.8 --push + + # Build with custom data directory + $0 -v 2.0.8 --data-dir /mnt/data/ainode +EOF +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) + VERSION="$2" + shift 2 + ;; + -p|--push) + PUSH_IMAGE=true + shift + ;; + -n|--no-cache) + NO_CACHE=true + shift + ;; + -t|--tag) + CUSTOM_TAG="$2" + shift 2 + ;; + -d|--data-dir) + DATA_DIR="$2" + shift 2 + ;; + -r|--registry) + REGISTRY_PREFIX="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Validate version +if [ -z "$VERSION" ]; then + echo "Error: Version is required. Use -v to specify." + usage + exit 1 +fi + +# Check if distribution package exists (relative to project root) +DIST_FILE="${PROJECT_ROOT}/distribution/target/apache-iotdb-${VERSION}-ainode-bin.zip" +if [ ! -f "$DIST_FILE" ]; then + echo "Error: Distribution file not found: $DIST_FILE" + echo "Please build the project first: mvn clean package -DskipTests" + exit 1 +fi + +# Check if data directory exists +if [ ! -d "$DATA_DIR" ]; then + echo "Warning: Data directory does not exist: $DATA_DIR" + echo "Creating empty directory..." + mkdir -p "$DATA_DIR" +fi + +# Determine image tag +if [ -n "$CUSTOM_TAG" ]; then + IMAGE_TAG="${CUSTOM_TAG}" +else + IMAGE_TAG="${VERSION}-${IMAGE_TAG_SUFFIX}" +fi + +# Construct full image name +if [ -n "$REGISTRY_PREFIX" ]; then + FULL_IMAGE_NAME="${REGISTRY_PREFIX}/${IMAGE_NAME}:${IMAGE_TAG}" +else + FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}" +fi + +echo "============================================" +echo "Building AINode Docker Image" +echo "============================================" +echo "Version: ${VERSION}" +echo "Distribution: ${DIST_FILE}" +echo "Data Directory: ${DATA_DIR}" +echo "Dockerfile: ${SCRIPT_DIR}/Dockerfile-2.0.x-ainode" +echo "Image Name: ${FULL_IMAGE_NAME}" +echo "Build Context: ${PROJECT_ROOT}" +echo "Script Dir: ${SCRIPT_DIR}" +echo "============================================" + +# Prepare data directory for Docker build context +# Use a unique name to avoid .dockerignore conflicts with common patterns like 'tmp*' +BUILD_DATA_DIR_NAME="ainode-build-data" +TMP_DATA_DIR="${SCRIPT_DIR}/${BUILD_DATA_DIR_NAME}" + +echo "Preparing data directory for build context at: ${TMP_DATA_DIR}" + +# Clean up old data if exists +if [ -d "$TMP_DATA_DIR" ]; then + echo "Cleaning up existing build data directory..." + rm -rf "$TMP_DATA_DIR" +fi + +# Create directory and copy data +mkdir -p "$TMP_DATA_DIR" +if [ -d "$DATA_DIR" ] && [ "$(ls -A $DATA_DIR 2>/dev/null)" ]; then + echo "Copying data from ${DATA_DIR} to ${TMP_DATA_DIR}..." + cp -r "$DATA_DIR"/* "$TMP_DATA_DIR/" + echo "Copied $(ls -1 "$TMP_DATA_DIR" | wc -l) items" +else + echo "No data to copy, created empty directory" +fi + +# Verify the directory exists and show contents +if [ ! -d "$TMP_DATA_DIR" ]; then + echo "Error: Failed to create temporary data directory: ${TMP_DATA_DIR}" + exit 1 +fi + +echo "Build data directory contents:" +ls -la "$TMP_DATA_DIR" || echo "(empty directory)" + +# Check if .dockerignore exists and might exclude our directory +DOCKERIGNORE_FILE="${PROJECT_ROOT}/.dockerignore" +if [ -f "$DOCKERIGNORE_FILE" ]; then + if grep -q "ainode-build-data" "$DOCKERIGNORE_FILE" || grep -qE "^\*|^tmp|^data" "$DOCKERIGNORE_FILE"; then + echo "" + echo "WARNING: .dockerignore file detected at ${DOCKERIGNORE_FILE}" + echo "It may exclude the '${BUILD_DATA_DIR_NAME}' directory from build context." + echo "If build fails with 'COPY failed', add exception to .dockerignore:" + echo " !docker/src/main/${BUILD_DATA_DIR_NAME}/" + echo "" + fi +fi + +# Cleanup function +cleanup() { + echo "Cleaning up temporary data directory: ${TMP_DATA_DIR}" + rm -rf "$TMP_DATA_DIR" +} +trap cleanup EXIT + +# Verify Dockerfile exists +DOCKERFILE="${SCRIPT_DIR}/Dockerfile-2.0.x-ainode" +if [ ! -f "$DOCKERFILE" ]; then + echo "Error: Dockerfile not found at: ${DOCKERFILE}" + exit 1 +fi + +# Build Docker image +# Build context is PROJECT_ROOT (3 levels up from current script) +BUILD_CMD="docker build" +BUILD_CMD+=" --file ${DOCKERFILE}" +BUILD_CMD+=" --build-arg VERSION=${VERSION}" +BUILD_CMD+=" --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" +BUILD_CMD+=" --build-arg VCS_REF=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" + +if [ "$NO_CACHE" = true ]; then + BUILD_CMD+=" --no-cache" +fi + +BUILD_CMD+=" --tag ${FULL_IMAGE_NAME}" +BUILD_CMD+=" ${PROJECT_ROOT}" + +echo "" +echo "Executing Docker build..." +echo "Command: ${BUILD_CMD}" +echo "" + +${BUILD_CMD} || { + echo "" + echo "Error: Docker build failed" + echo "" + echo "Troubleshooting tips:" + echo "1. If error is 'COPY failed: no such file or directory', check if .dockerignore excludes 'docker/src/main/${BUILD_DATA_DIR_NAME}/'" + echo "2. Ensure Docker daemon is running" + echo "3. Try running with --no-cache option" + exit 1 +} + +echo "" +echo "Build completed successfully: ${FULL_IMAGE_NAME}" + +# Push image if requested +if [ "$PUSH_IMAGE" = true ]; then + echo "Pushing image to registry..." + docker push "${FULL_IMAGE_NAME}" + + # Also push latest tag for release versions + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + LATEST_TAG="latest-${IMAGE_TAG_SUFFIX}" + if [ -n "$REGISTRY_PREFIX" ]; then + LATEST_NAME="${REGISTRY_PREFIX}/${IMAGE_NAME}:${LATEST_TAG}" + else + LATEST_NAME="${IMAGE_NAME}:${LATEST_TAG}" + fi + echo "Tagging and pushing: ${LATEST_NAME}" + docker tag "${FULL_IMAGE_NAME}" "${LATEST_NAME}" + docker push "${LATEST_NAME}" + fi +fi + +echo "" +echo "Done!" \ No newline at end of file