diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 00000000..f10a9de7 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,3 @@ +--- +extends: + - "@commitlint/config-conventional" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e6860f5..ba7dbdbe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,4 +12,4 @@ } }, "remoteUser": "vscode" -} +} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/dev_story_template.yml b/.github/ISSUE_TEMPLATE/dev_story_template.yml index 2b0249ef..f76da3bf 100644 --- a/.github/ISSUE_TEMPLATE/dev_story_template.yml +++ b/.github/ISSUE_TEMPLATE/dev_story_template.yml @@ -8,8 +8,8 @@ body: - type: markdown attributes: value: | - Describe the use-case to be implemented from the user-persona point of view. - There can be multiple workflows which achieve the use-case + Describe the use-case to be implemented from the user-persona point of view. + There can be multiple workflows which achieve the use-case - type: textarea id: description attributes: @@ -61,11 +61,11 @@ body: label: "Definition of Done" description: "Describe the Definition of Done, which might be a use-case demonstrated to the TWG" placeholder: | - * The required technical documentation should be updated - * In case of any prototyping activity, the evaluation, design and outcomes need to be documented - * API updates need to be updated in swagger (or other) files - * Test automation should be done to demonstrate the use-case - * Code and documentation should be pulled into the release branch and the pull-request approved by the TWG + * The required technical documentation should be updated + * In case of any prototyping activity, the evaluation, design and outcomes need to be documented + * API updates need to be updated in swagger (or other) files + * Test automation should be done to demonstrate the use-case + * Code and documentation should be pulled into the release branch and the pull-request approved by the TWG validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/dev_task_template.yml b/.github/ISSUE_TEMPLATE/dev_task_template.yml index 9f9fc273..223695b9 100644 --- a/.github/ISSUE_TEMPLATE/dev_task_template.yml +++ b/.github/ISSUE_TEMPLATE/dev_task_template.yml @@ -8,8 +8,8 @@ body: - type: markdown attributes: value: | - Describe the Task. This may be to do a design, prototype, test or documentation. - Ideally a task should be something that can be handled by an individual within a 5-6 story-points in size. + Describe the Task. This may be to do a design, prototype, test or documentation. + Ideally a task should be something that can be handled by an individual within a 5-6 story-points in size. - type: textarea id: description attributes: diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml index 21810b2c..77d70c92 100644 --- a/.github/ISSUE_TEMPLATE/feedback.yml +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -1,7 +1,7 @@ name: "Feedback" description: "An open feedback form for the Margo Code First Sandbox" title: "[FB: ]" -labels: ["DRAFT","Feedback"] +labels: ["DRAFT", "Feedback"] assignees: - "nilanjan-samajdar" body: @@ -18,8 +18,8 @@ body: - type: markdown attributes: value: | - Please provide full details regarding your feedback towards the Code First sandbox below. - If referring to specific files in documentation or code, please provide file-name and line-number. + Please provide full details regarding your feedback towards the Code First sandbox below. + If referring to specific files in documentation or code, please provide file-name and line-number. - type: textarea id: feedback attributes: diff --git a/.github/workflows/lint-commit.yml b/.github/workflows/lint-commit.yml new file mode 100644 index 00000000..15fdea85 --- /dev/null +++ b/.github/workflows/lint-commit.yml @@ -0,0 +1,25 @@ +name: Lint Commit Messages +on: + push: + branches: + - main + - develop + pull_request: + +permissions: + contents: read + +jobs: + lint-commit: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Required to fetch all commits in the PR history + - name: Install commitlint + run: npm install -D @commitlint/cli @commitlint/config-conventional + - name: Validate PR commits + # Lints from the PR base branch to the current head + run: | + npx commitlint --from ${{ github.event.pull_request.base.sha }} \ + --to ${{ github.event.pull_request.head.sha }} --verbose diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 0843542e..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Code Style Check - -on: - pull_request: - -jobs: - gofmt: - continue-on-error: true - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25' - - - name: Check Go code format - run: find . -type f -name "*.go" -exec gofmt -l {} \; - - - name: Check Go code subtle issues - run: go vet ./... diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 00000000..e4ce74cc --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,74 @@ +name: Quality Gates + +on: + push: + branches: + - main + - develop + pull_request: + +permissions: + contents: read + pull-requests: read + +jobs: + lint-yaml: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: ibiqlik/action-yamllint@v2 + with: + file_or_dir: .github docker-compose helmchart poc + format: github + config_data: | + extends: relaxed + rules: + line-length: + max: 80 + level: warning + indentation: disable + + + lint-go: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 1.24.4 + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.11 + + lint-container-manifests: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Install Helm + uses: azure/setup-helm@v3 + - name: Lint Helm charts + run: helm lint ./helmchart + - name: Validate docker-compose files + run: | + docker compose -f docker-compose/docker-compose.yaml config > /dev/null + + test-go: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 1.24.4 + - name: Run tests with coverage + run: go test -v -coverprofile=coverage.out ./... + - name: Check coverage threshold + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Code coverage is: ${COVERAGE}%" + # NOTE: we are printing the test coverage but not failing the pipeline as of now + # once the test cases are there and code coverage threshold is decided + # then we will enforce the minimum threshold check + # and can push to codecov as well + # if (( $(echo "$COVERAGE < 80" | bc -l) )); then + # exit 1 + # fi diff --git a/.github/workflows/sandbox-sanity-test.yml b/.github/workflows/sandbox-sanity-test.yml index 1e6b9173..b38aa44b 100644 --- a/.github/workflows/sandbox-sanity-test.yml +++ b/.github/workflows/sandbox-sanity-test.yml @@ -6,19 +6,31 @@ on: branches: - development paths-ignore: - - '**.md' - - 'docs/**' - - # Allow manual trigger + - "**.md" + - "docs/**" + pull_request: + branches: + - development workflow_dispatch: + inputs: + symphony_branch_override: + description: "Override SYMPHONY_BRANCH" + required: false + default: "development" + sandbox_repo_branch_override: + description: "Override SANDBOX_REPO_BRANCH" + required: false + default: "development" + jobs: sandbox-e2e-functional-test-job: runs-on: ubuntu-24.04 env: CI: "true" # Branches - SYMPHONY_BRANCH: development - SANDBOX_REPO_BRANCH: development + SYMPHONY_BRANCH: ${{ github.event.inputs.symphony_branch_override || 'development' }} + SANDBOX_REPO_BRANCH: ${{ github.event.inputs.sandbox_repo_branch_override || 'development' }} + # Ports EXPOSED_HARBOR_PORT: 8443 EXPOSED_SYMPHONY_PORT: 8082 @@ -58,7 +70,7 @@ jobs: netcat-openbsd \ openssl \ git - + # ============================================================ # Make pipeline scripts executable # ============================================================ @@ -122,7 +134,7 @@ jobs: $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update - + # ============================================================ # WFM Install (clones Symphony, starts harbor etc...) # ============================================================ @@ -135,17 +147,17 @@ jobs: - name: Trust Harbor CA in K3s run: | echo "🔐 Configuring K3s to trust Harbor CA..." - + HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" - + # Copy to K3s containerd certs directory sudo mkdir -p /var/lib/rancher/k3s/agent/etc/containerd/certs.d/harbor.machine:${EXPOSED_HARBOR_PORT} sudo cp "$HARBOR_CERT" /var/lib/rancher/k3s/agent/etc/containerd/certs.d/harbor.machine:${EXPOSED_HARBOR_PORT}/ca.crt - + # Also add to system trust store for K3s sudo cp "$HARBOR_CERT" /usr/local/share/ca-certificates/harbor-k3s.crt sudo update-ca-certificates - + echo "✅ Harbor CA trusted in K3s" # ============================================================ @@ -162,7 +174,7 @@ jobs: - name: Build Images in Parallel run: | echo "🔨 Building Symphony API and WFM-Client in parallel..." - + # Build Symphony in background ( cd "$HOME/symphony" @@ -172,7 +184,7 @@ jobs: . && echo "✅ Symphony built" ) & SYMPHONY_PID=$! - + # Build WFM-Client in background ( cd "$HOME/sandbox" @@ -182,29 +194,28 @@ jobs: . && echo "✅ WFM-Client built" ) & CLIENT_PID=$! - + # Wait for both wait $SYMPHONY_PID || exit 1 wait $CLIENT_PID || exit 1 - + echo "✅ All images built successfully" - + # Import WFM-Client into K3s docker save workload-fleet-management-client:ci-test | sudo k3s ctr images import - - - name: Start WFM timeout-minutes: 5 working-directory: pipeline run: | echo "Starting Symphony with locally built image..." - + # Override SYMPHONY_IMAGE_REF to use local CI-built image export SYMPHONY_IMAGE_REF="symphony-api:ci-test" - + # Start Symphony using wfm.sh (handles Maestro build + TLS + container start) sudo -E bash ./wfm.sh start - + # Wait for Symphony API to be ready echo "⏳ Waiting for Symphony API to be ready..." for i in {1..30}; do @@ -215,15 +226,13 @@ jobs: echo "Waiting for Symphony API... ($i/30)" sleep 2 done - - - + - name: Setup ORAS authentication run: | echo "📦 Creating isolated Docker config for ORAS..." export DOCKER_CONFIG=$(mktemp -d) echo "Using DOCKER_CONFIG=$DOCKER_CONFIG" - + # Trust Harbor CA certificate first HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" if [ -f "$HARBOR_CERT" ]; then @@ -233,18 +242,17 @@ jobs: else echo "⚠️ Harbor certificate not found at $HARBOR_CERT" fi - - # Login to Harbor with HTTPS + + # Login to Harbor with HTTPS echo "${REGISTRY_PASS}" | oras login harbor.machine:${EXPOSED_HARBOR_PORT} \ -u "${REGISTRY_USER}" \ --password-stdin - + # Ensure Docker config has correct permissions chmod 700 "$DOCKER_CONFIG" find "$DOCKER_CONFIG" -type f -exec chmod 600 {} \; - + echo "✅ ORAS authenticated to Harbor (HTTPS)" - # ------------------------------------------------ # Copy the WFM CA certificate @@ -254,20 +262,20 @@ jobs: CERT="$HOME/symphony/api/certificates/ca-cert.pem" mkdir -p "$HOME/certs" cp "$CERT" "$HOME/certs/" - echo "✅ CA certs copied to $HOME/certs/" - + echo "✅ CA certs copied to $HOME/certs/" + - name: Upload app packages in parallel working-directory: pipeline env: MAESTRO_CLI_PATH: /home/runner/symphony/cli run: | echo "📤 Uploading packages in parallel..." - + # Upload custom-otel-helm-app-package in background ( echo "📦 Uploading ${APP_PACKAGE_NAME_K3S}..." sudo -E bash ./wfm-cli.sh upload-app-non-interactive "${APP_PACKAGE_NAME_K3S}" - + for i in {1..10}; do STATUS=$(sudo -E bash ./wfm-cli.sh list-packages 2>/dev/null | grep "${APP_PACKAGE_NAME_K3S}" | grep -o "ONBOARDED" || echo "") if [ "$STATUS" = "ONBOARDED" ]; then @@ -278,12 +286,12 @@ jobs: done ) & K3S_PKG_PID=$! - + # Upload nextcloud-compose-app-package in background ( echo "📦 Uploading ${APP_PACKAGE_NAME_DOCKER}..." sudo -E bash ./wfm-cli.sh upload-app-non-interactive "${APP_PACKAGE_NAME_DOCKER}" - + for i in {1..10}; do STATUS=$(sudo -E bash ./wfm-cli.sh list-packages 2>/dev/null | grep "${APP_PACKAGE_NAME_DOCKER}" | grep -o "ONBOARDED" || echo "") if [ "$STATUS" = "ONBOARDED" ]; then @@ -294,13 +302,12 @@ jobs: done ) & DOCKER_PKG_PID=$! - + # Wait for both uploads to complete wait $K3S_PKG_PID || { echo "❌ ${APP_PACKAGE_NAME_K3S} upload failed"; exit 1; } wait $DOCKER_PKG_PID || { echo "❌ ${APP_PACKAGE_NAME_DOCKER} upload failed"; exit 1; } - - echo "✅ All packages uploaded successfully" + echo "✅ All packages uploaded successfully" - name: List all packages and devices working-directory: pipeline @@ -310,7 +317,6 @@ jobs: echo "📋 Listing app packages..." sudo -E bash ./wfm-cli.sh list-packages - - name: WFM-Client (K3s) – Setup working-directory: pipeline run: | @@ -318,79 +324,56 @@ jobs: sudo -E bash ./device-agent.sh k3s create-rsa-certs sudo -E bash ./device-agent.sh k3s create-ecdsa-certs - - name: Configure CoreDNS for k3s run: | RUNNER_IP=${RUNNER_IP} echo "📡 Using K3s kubeconfig" sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes - - # Fetch Corefile + + # Get the full ConfigMap YAML (not just Corefile) sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' > Corefile - - echo "Original Corefile:" - cat Corefile - - # ✅ CORRECTED: Insert IP addresses into existing hosts block - if grep -q 'hosts /etc/coredns/NodeHosts' Corefile; then - echo "hosts block exists: adding custom entries" - awk -v ip="$RUNNER_IP" ' - /hosts \/etc\/coredns\/NodeHosts/ { - print - getline - print - print " " ip " harbor.machine" - print " " ip " symphony.machine" - next - } - { print } - ' Corefile > Corefile.new - else - echo "No hosts block found: creating new one" - awk -v ip="$RUNNER_IP" ' - /prometheus/ && !x { - print " hosts /etc/coredns/NodeHosts {" - print " " ip " harbor.machine" - print " " ip " symphony.machine" - print " ttl 60" - print " reload 15s" - print " fallthrough" - print " }" - x=1 - } - { print } - ' Corefile > Corefile.new - fi - - # Apply config - echo "New Corefile content:" - cat Corefile.new - + kubectl -n kube-system get configmap coredns -o yaml > config.yaml + + echo "Original ConfigMap:" + cat config.yaml + + # Update the Corefile section with our entries inside the NodeHosts block + awk -v ip="$RUNNER_IP" ' + /hosts \/etc\/coredns\/NodeHosts/ { + print + getline + print " " ip " harbor.machine" + print " " ip " symphony.machine" + print + next + } + { print } + ' config.yaml > config-new.yaml + + echo "New ConfigMap:" + cat config-new.yaml + + # Apply the updated ConfigMap sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system create configmap coredns \ - --from-file=Corefile=Corefile.new \ - -o yaml --dry-run=client | \ - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -f - - - # Restart CoreDNS + kubectl apply -f config-new.yaml + + # Restart CoreDNS deployment sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ kubectl -n kube-system rollout restart deployment coredns - # Wait for rollout to complete (instead of fixed 20s) + # Wait for rollout to complete sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system rollout status deployment/coredns --timeout=30s - - sleep 10 # Extra wait to ensure CoreDNS is fully ready after restart - echo "✅ CoreDNS restarted" + kubectl -n kube-system rollout status deployment/coredns --timeout=60s + # Give extra time for DNS to propagate + sleep 10 + echo "✅ CoreDNS restarted with updated configuration" - name: WFM-Client (Standalone Cluster) – Start working-directory: pipeline run: | sudo -E bash ./device-agent.sh k3s start-k3s - - + echo "⏳ Waiting for WFM-Client pod to be ready..." for i in {1..30}; do if sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods | grep workload-fleet-management-client | grep -q "Running"; then @@ -409,8 +392,8 @@ jobs: sudo -E bash ./device-agent.sh docker create-rsa-certs sudo -E bash ./device-agent.sh docker create-ecdsa-certs sudo -E bash ./device-agent.sh docker start-docker - - + + echo "⏳ Waiting for WFM-Client container to be ready..." for i in {1..30}; do if docker ps --format "{{.Names}}" | grep -q "^workload-fleet-management-client$"; then @@ -423,26 +406,65 @@ jobs: sleep 2 done - - name: 🔧 Install Helm & Authenticate Harbor + - name: Verify DNS resolution in WFM-Client pod + run: | + echo "🔍 Verifying DNS resolution in WFM-Client pod..." + POD_NAME=$(sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods \ + -o name | grep workload-fleet-management-client | head -1 | cut -d'/' -f2) + echo "Testing DNS resolution for harbor.machine..." + for i in {1..30}; do + if sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- \ + nslookup harbor.machine >/dev/null 2>&1; then + RESOLVED_IP=$(sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- \ + nslookup harbor.machine 2>/dev/null | grep -A1 "Name:" | grep "Address:" | awk '{print $2}' | head -1) + echo "✅ DNS working - harbor.machine resolves to: $RESOLVED_IP" + if [ "$RESOLVED_IP" = "${RUNNER_IP}" ]; then + echo "✅ DNS resolves to correct IP (${RUNNER_IP})" + break + else + echo "⚠️ DNS resolves to wrong IP. Expected: ${RUNNER_IP}, Got: $RESOLVED_IP" + fi + fi + echo "Waiting for DNS propagation in pod... ($i/30)" + sleep 2 + done + # Final verification with detailed output + echo "Final DNS check:" + sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- nslookup harbor.machine || { + echo "❌ DNS still not working after 60 seconds" + echo "" + echo "Debugging information:" + echo "CoreDNS pods:" + sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods -n kube-system -l k8s-app=kube-dns + echo "" + echo "CoreDNS logs:" + sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl logs -n kube-system deployment/coredns --tail=50 + echo "" + echo "Pod's /etc/resolv.conf:" + sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- cat /etc/resolv.conf + exit 1 + } + + - name: 🔧 Install Helm & Authenticate Harbor shell: bash run: | set -e echo "🔍 Finding WFM client pod..." POD_NAME=$(sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods \ -o name | grep workload-fleet-management-client | head -1 | cut -d'/' -f2) - + if [ -z "$POD_NAME" ]; then echo "❌ Could not find WFM client pod" sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods -A exit 1 fi - + echo "✅ Found pod: $POD_NAME" - + # Copy Harbor CA certificate into pod HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl cp "$HARBOR_CERT" "$POD_NAME:/tmp/harbor-ca.crt" - + # Create setup script cat > /tmp/setup-helm.sh <<'EOF' #!/bin/sh @@ -487,14 +509,14 @@ jobs: echo "🔑 Logging into Harbor..." echo "${REGISTRY_PASS}" | helm registry login harbor.machine:${EXPOSED_HARBOR_PORT} \ -u "${REGISTRY_USER}" \ - --password-stdin + --password-stdin echo "✅ Helm + Harbor setup completed" EOF # Copy script to pod sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl cp /tmp/setup-helm.sh "$POD_NAME:/tmp/setup-helm.sh" - + # Execute script with environment variables sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- sh -c " export REGISTRY_USER='${REGISTRY_USER}' @@ -504,34 +526,33 @@ jobs: /tmp/setup-helm.sh " - - name: 🚀 Deploy to Both Devices in Parallel working-directory: pipeline env: MAESTRO_CLI_PATH: /home/runner/symphony/cli run: | echo "🚀 Deploying to both devices in parallel..." - + # Deploy to K3s in background ( echo "📋 Deploying to K3s..." PACKAGE_ID=$(sudo -E bash ./wfm-cli.sh get-package-id-by-name "${APP_PACKAGE_NAME_K3S}" | tail -n1) DEVICE_ID=$(sudo -E bash ./wfm-cli.sh get-device-id-by-role "Standalone Cluster" | tail -n1) - + if [ -z "$PACKAGE_ID" ] || [ "$PACKAGE_ID" = "null" ] || [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "null" ]; then echo "❌ K3s: Invalid IDs" exit 1 fi - + DEPLOY_OUTPUT=$(sudo -E bash ./wfm-cli.sh deploy-non-interactive "$PACKAGE_ID" "$DEVICE_ID") DEPLOYMENT_ID=$(echo "$DEPLOY_OUTPUT" | grep -oP 'deploymentId \K[a-f0-9-]+' | head -1) echo "📦 K3s Deployment ID: $DEPLOYMENT_ID" - + # Monitor K3s deployment K="sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl" for ATTEMPT in {1..60}; do DEPLOYMENT_STATUS=$(sudo -E bash ./wfm-cli.sh list-deployments 2>/dev/null | grep "${DEPLOYMENT_ID:0:10}" | awk '{print $NF}' || echo "") - + if [ "$DEPLOYMENT_STATUS" = "INSTALLED" ]; then echo "✅ K3s deployment completed!" break @@ -544,26 +565,26 @@ jobs: done ) & K3S_DEPLOY_PID=$! - + # Deploy to Docker in background ( echo "📋 Deploying to Docker..." PACKAGE_ID=$(sudo -E bash ./wfm-cli.sh get-package-id-by-name "${APP_PACKAGE_NAME_DOCKER}" | tail -n1) DEVICE_ID=$(sudo -E bash ./wfm-cli.sh get-device-id-by-role "Standalone Device" | tail -n1) - + if [ -z "$PACKAGE_ID" ] || [ "$PACKAGE_ID" = "null" ] || [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "null" ]; then echo "❌ Docker: Invalid IDs" exit 1 fi - + DEPLOY_OUTPUT=$(sudo -E bash ./wfm-cli.sh deploy-non-interactive "$PACKAGE_ID" "$DEVICE_ID") DEPLOYMENT_ID=$(echo "$DEPLOY_OUTPUT" | grep -oP 'deploymentId \K[a-f0-9-]+' | head -1) echo "📦 Docker Deployment ID: $DEPLOYMENT_ID" - + # Monitor Docker deployment for ATTEMPT in {1..60}; do DEPLOYMENT_STATUS=$(sudo -E bash ./wfm-cli.sh list-deployments 2>/dev/null | grep "${DEPLOYMENT_ID:0:10}" | awk '{print $NF}' || echo "") - + if [ "$DEPLOYMENT_STATUS" = "INSTALLED" ]; then echo "✅ Docker deployment completed!" break @@ -576,13 +597,12 @@ jobs: done ) & DOCKER_DEPLOY_PID=$! - + # Wait for both deployments wait $K3S_DEPLOY_PID || exit 1 wait $DOCKER_DEPLOY_PID || exit 1 - - echo "✅ Both deployments completed successfully!" + echo "✅ Both deployments completed successfully!" - name: 📋 Final Deployment Status working-directory: pipeline diff --git a/.github/workflows/testWF.yml b/.github/workflows/testWF.yml deleted file mode 100644 index 6c6be45a..00000000 --- a/.github/workflows/testWF.yml +++ /dev/null @@ -1,595 +0,0 @@ -name: sandbox-sanity-test - -on: - # Trigger on push to development branch - push: - branches: - - development - paths-ignore: - - '**.md' - - 'docs/**' - - # Allow manual trigger - workflow_dispatch: -jobs: - sandbox-e2e-functional-test-job: - runs-on: ubuntu-24.04 - env: - CI: "true" - # Branches - SYMPHONY_BRANCH: development - SANDBOX_REPO_BRANCH: development - # Ports - EXPOSED_HARBOR_PORT: 8443 - EXPOSED_SYMPHONY_PORT: 8082 - # Optional GitHub auth - GITHUB_USER: "" - GITHUB_TOKEN: "" - APP_PACKAGE_NAME_K3S: custom-otel-helm-app-package - APP_PACKAGE_NAME_DOCKER: nextcloud-compose-app-package - MAESTRO_CLI_PATH: "/home/runner/symphony/cli" - REGISTRY_USER: "admin" - REGISTRY_PASS: "${{ secrets.HARBOR_PASSWORD }}" - REGISTRY: "harbor.machine:8443" - - steps: - # ============================================================ - # Checkout - # ============================================================ - - name: Checkout repository - uses: actions/checkout@v4 - # ============================================================ - # Detect runner IP (NOT localhost) - # ============================================================ - - name: Environment - Detect runner IP - run: | - RUNNER_IP=$(hostname -I | awk '{print $1}') - echo "RUNNER_IP=$RUNNER_IP" >> $GITHUB_ENV - echo "✅ Runner IP: $RUNNER_IP" - # ============================================================ - # OS prerequisites (NO docker install) - # ============================================================ - - name: Environment - Install OS dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y \ - curl \ - jq \ - netcat-openbsd \ - openssl \ - git - - # ============================================================ - # Make pipeline scripts executable - # ============================================================ - - name: Environment - Make scripts executable - run: chmod +x pipeline/*.sh - # ============================================================ - # Configure hostnames for Harbor and Symphony - # ============================================================ - - name: Environment - Configure /etc/hosts - run: | - echo "🔧 Updating /etc/hosts" - echo "${RUNNER_IP} harbor.machine" | sudo tee -a /etc/hosts - echo "${RUNNER_IP} symphony.machine" | sudo tee -a /etc/hosts - echo "✅ /etc/hosts updated:" - cat /etc/hosts - # ============================================================ - # Generate wfm.env (MANDATORY) - # ============================================================ - - name: Generate wfm.env for WFM - run: | - cat > pipeline/wfm.env < pipeline/device-agent.env < /dev/null - sudo apt-get update - - # ============================================================ - # WFM Install (clones Symphony, starts harbor etc...) - # ============================================================ - - name: Install WFM Components - working-directory: pipeline - run: | - echo "Installing WFM / Symphony" - sudo -E bash ./wfm.sh install - - - name: Trust Harbor CA in K3s - run: | - echo "🔐 Configuring K3s to trust Harbor CA..." - - HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" - - # Copy to K3s containerd certs directory - sudo mkdir -p /var/lib/rancher/k3s/agent/etc/containerd/certs.d/harbor.machine:${EXPOSED_HARBOR_PORT} - sudo cp "$HARBOR_CERT" /var/lib/rancher/k3s/agent/etc/containerd/certs.d/harbor.machine:${EXPOSED_HARBOR_PORT}/ca.crt - - # Also add to system trust store for K3s - sudo cp "$HARBOR_CERT" /usr/local/share/ca-certificates/harbor-k3s.crt - sudo update-ca-certificates - - echo "✅ Harbor CA trusted in K3s" - - # ============================================================ - # Build Symphony/WFM-Client from source (local only, no push) - # ============================================================ - - name: Fix Docker Permissions - run: | - echo "🔧 Fixing Docker permissions..." - sudo chown -R $USER:$USER $HOME/.docker 2>/dev/null || true - mkdir -p $HOME/.docker/buildx - sudo chown -R $USER:$USER $HOME/.docker/buildx 2>/dev/null || true - echo "✅ Docker permissions fixed" - - - name: Build Images in Parallel - run: | - echo "🔨 Building Symphony API and WFM-Client in parallel..." - - # Build Symphony in background - ( - cd "$HOME/symphony" - DOCKER_BUILDKIT=1 docker build \ - --tag symphony-api:ci-test \ - --file api/Dockerfile \ - . && echo "✅ Symphony built" - ) & - SYMPHONY_PID=$! - - # Build WFM-Client in background - ( - cd "$HOME/sandbox" - DOCKER_BUILDKIT=1 docker build \ - --tag workload-fleet-management-client:ci-test \ - --file poc/device/agent/Dockerfile \ - . && echo "✅ WFM-Client built" - ) & - CLIENT_PID=$! - - # Wait for both - wait $SYMPHONY_PID || exit 1 - wait $CLIENT_PID || exit 1 - - echo "✅ All images built successfully" - - # Import WFM-Client into K3s - docker save workload-fleet-management-client:ci-test | sudo k3s ctr images import - - - - - name: Start WFM - timeout-minutes: 5 - working-directory: pipeline - run: | - echo "Starting Symphony with locally built image..." - - # Override SYMPHONY_IMAGE_REF to use local CI-built image - export SYMPHONY_IMAGE_REF="symphony-api:ci-test" - - # Start Symphony using wfm.sh (handles Maestro build + TLS + container start) - sudo -E bash ./wfm.sh start - - # Wait for Symphony API to be ready - echo "⏳ Waiting for Symphony API to be ready..." - for i in {1..30}; do - if curl -k -s https://symphony.machine:8082/health >/dev/null 2>&1; then - echo "✅ Symphony API is ready" - break - fi - echo "Waiting for Symphony API... ($i/30)" - sleep 2 - done - - - - - name: Setup ORAS authentication - run: | - echo "📦 Creating isolated Docker config for ORAS..." - export DOCKER_CONFIG=$(mktemp -d) - echo "Using DOCKER_CONFIG=$DOCKER_CONFIG" - - # Trust Harbor CA certificate first - HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" - if [ -f "$HARBOR_CERT" ]; then - sudo cp "$HARBOR_CERT" /usr/local/share/ca-certificates/harbor.crt - sudo update-ca-certificates - echo "✅ Harbor CA certificate trusted" - else - echo "⚠️ Harbor certificate not found at $HARBOR_CERT" - fi - - # Login to Harbor with HTTPS - echo "${REGISTRY_PASS}" | oras login harbor.machine:${EXPOSED_HARBOR_PORT} \ - -u "${REGISTRY_USER}" \ - --password-stdin - - # Ensure Docker config has correct permissions - chmod 700 "$DOCKER_CONFIG" - find "$DOCKER_CONFIG" -type f -exec chmod 600 {} \; - - echo "✅ ORAS authenticated to Harbor (HTTPS)" - - - # ------------------------------------------------ - # Copy the WFM CA certificate - # ------------------------------------------------ - - name: WFM - Copy the CA cert to home directory for others to use - run: | - CERT="$HOME/symphony/api/certificates/ca-cert.pem" - mkdir -p "$HOME/certs" - cp "$CERT" "$HOME/certs/" - echo "✅ CA certs copied to $HOME/certs/" - - - name: Upload app packages in parallel - working-directory: pipeline - env: - MAESTRO_CLI_PATH: /home/runner/symphony/cli - run: | - echo "📤 Uploading packages in parallel..." - - # Upload custom-otel-helm-app-package in background - ( - echo "📦 Uploading ${APP_PACKAGE_NAME_K3S}..." - sudo -E bash ./wfm-cli.sh upload-app-non-interactive "${APP_PACKAGE_NAME_K3S}" - - for i in {1..10}; do - STATUS=$(sudo -E bash ./wfm-cli.sh list-packages 2>/dev/null | grep "${APP_PACKAGE_NAME_K3S}" | grep -o "ONBOARDED" || echo "") - if [ "$STATUS" = "ONBOARDED" ]; then - echo "✅ ${APP_PACKAGE_NAME_K3S} onboarded" - break - fi - sleep 2 - done - ) & - K3S_PKG_PID=$! - - # Upload nextcloud-compose-app-package in background - ( - echo "📦 Uploading ${APP_PACKAGE_NAME_DOCKER}..." - sudo -E bash ./wfm-cli.sh upload-app-non-interactive "${APP_PACKAGE_NAME_DOCKER}" - - for i in {1..10}; do - STATUS=$(sudo -E bash ./wfm-cli.sh list-packages 2>/dev/null | grep "${APP_PACKAGE_NAME_DOCKER}" | grep -o "ONBOARDED" || echo "") - if [ "$STATUS" = "ONBOARDED" ]; then - echo "✅ ${APP_PACKAGE_NAME_DOCKER} onboarded" - break - fi - sleep 2 - done - ) & - DOCKER_PKG_PID=$! - - # Wait for both uploads to complete - wait $K3S_PKG_PID || { echo "❌ ${APP_PACKAGE_NAME_K3S} upload failed"; exit 1; } - wait $DOCKER_PKG_PID || { echo "❌ ${APP_PACKAGE_NAME_DOCKER} upload failed"; exit 1; } - - echo "✅ All packages uploaded successfully" - - - - name: List all packages and devices - working-directory: pipeline - env: - MAESTRO_CLI_PATH: /home/runner/symphony/cli - run: | - echo "📋 Listing app packages..." - sudo -E bash ./wfm-cli.sh list-packages - - - - name: WFM-Client (K3s) – Setup - working-directory: pipeline - run: | - sudo -E bash ./device-agent.sh k3s install - sudo -E bash ./device-agent.sh k3s create-rsa-certs - sudo -E bash ./device-agent.sh k3s create-ecdsa-certs - - - - name: Configure CoreDNS for k3s - run: | - RUNNER_IP=${RUNNER_IP} - echo "📡 Using K3s kubeconfig" - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes - - # Fetch Corefile - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' > Corefile - - echo "Original Corefile:" - cat Corefile - - # ✅ CORRECTED: Insert IP addresses into existing hosts block - if grep -q 'hosts /etc/coredns/NodeHosts' Corefile; then - echo "hosts block exists: adding custom entries" - awk -v ip="$RUNNER_IP" ' - /hosts \/etc\/coredns\/NodeHosts/ { - print - getline - print - print " " ip " harbor.machine" - print " " ip " symphony.machine" - next - } - { print } - ' Corefile > Corefile.new - else - echo "No hosts block found: creating new one" - awk -v ip="$RUNNER_IP" ' - /prometheus/ && !x { - print " hosts /etc/coredns/NodeHosts {" - print " " ip " harbor.machine" - print " " ip " symphony.machine" - print " ttl 60" - print " reload 15s" - print " fallthrough" - print " }" - x=1 - } - { print } - ' Corefile > Corefile.new - fi - - # Apply config - echo "New Corefile content:" - cat Corefile.new - - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system create configmap coredns \ - --from-file=Corefile=Corefile.new \ - -o yaml --dry-run=client | \ - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl apply -f - - - # Restart CoreDNS - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system rollout restart deployment coredns - - # Wait for rollout to complete (instead of fixed 20s) - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml \ - kubectl -n kube-system rollout status deployment/coredns --timeout=30s - sleep 10 - echo "✅ CoreDNS restarted" - - - - name: WFM-Client (Standalone Cluster) – Start - working-directory: pipeline - run: | - sudo -E bash ./device-agent.sh k3s start-k3s - - - echo "⏳ Waiting for WFM-Client pod to be ready..." - for i in {1..30}; do - if sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods | grep workload-fleet-management-client | grep -q "Running"; then - echo "✅ WFM-Client pod is running" - # Wait for device to onboard - sleep 3 - break - fi - echo "Waiting for pod... ($i/30)" - sleep 2 - done - - - name: WFM-Client (Standalone Device) – Start - working-directory: pipeline - run: | - sudo -E bash ./device-agent.sh docker create-rsa-certs - sudo -E bash ./device-agent.sh docker create-ecdsa-certs - sudo -E bash ./device-agent.sh docker start-docker - - - echo "⏳ Waiting for WFM-Client container to be ready..." - for i in {1..30}; do - if docker ps --format "{{.Names}}" | grep -q "^workload-fleet-management-client$"; then - echo "✅ Docker WFM-Client container is running" - docker ps --filter "name=workload-fleet-management-client" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}" - sleep 5 - break - fi - echo "Waiting for Docker container... ($i/30)" - sleep 2 - done - - - name: 🔧 Install Helm & Authenticate Harbor - shell: bash - run: | - set -e - echo "🔍 Finding WFM client pod..." - POD_NAME=$(sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods \ - -o name | grep workload-fleet-management-client | head -1 | cut -d'/' -f2) - - if [ -z "$POD_NAME" ]; then - echo "❌ Could not find WFM client pod" - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pods -A - exit 1 - fi - - echo "✅ Found pod: $POD_NAME" - - # Copy Harbor CA certificate into pod - HARBOR_CERT="$HOME/sandbox/pipeline/harbor/certs/harbor.crt" - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl cp "$HARBOR_CERT" "$POD_NAME:/tmp/harbor-ca.crt" - - # Create setup script - cat > /tmp/setup-helm.sh <<'EOF' - #!/bin/sh - set -e - - # Trust Harbor CA certificate - echo "🔐 Trusting Harbor CA certificate..." - mkdir -p /usr/local/share/ca-certificates - cp /tmp/harbor-ca.crt /usr/local/share/ca-certificates/harbor.crt - update-ca-certificates 2>/dev/null || true - - # Install Helm - echo "📦 Installing Helm..." - if ! command -v helm >/dev/null 2>&1; then - if command -v wget >/dev/null 2>&1; then - wget -q https://get.helm.sh/helm-v3.15.1-linux-amd64.tar.gz -O /tmp/helm.tar.gz - elif command -v curl >/dev/null 2>&1; then - curl -fsSL https://get.helm.sh/helm-v3.15.1-linux-amd64.tar.gz -o /tmp/helm.tar.gz - else - echo "❌ Neither wget nor curl available" - exit 1 - fi - tar -xzf /tmp/helm.tar.gz -C /tmp - mv /tmp/linux-amd64/helm /usr/local/bin/helm - chmod +x /usr/local/bin/helm - rm -rf /tmp/helm.tar.gz /tmp/linux-amd64 - echo "✅ Helm installed" - else - echo "✅ Helm already installed" - fi - - helm version - - # Setup Docker config - echo "🔐 Setting Docker config..." - mkdir -p /root/.docker - echo "{}" > /root/.docker/config.json - export DOCKER_CONFIG=/root/.docker - export HELM_EXPERIMENTAL_OCI=1 - - # Login to Harbor with HTTPS - echo "🔑 Logging into Harbor..." - echo "${REGISTRY_PASS}" | helm registry login harbor.machine:${EXPOSED_HARBOR_PORT} \ - -u "${REGISTRY_USER}" \ - --password-stdin - - echo "✅ Helm + Harbor setup completed" - EOF - - # Copy script to pod - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl cp /tmp/setup-helm.sh "$POD_NAME:/tmp/setup-helm.sh" - - # Execute script with environment variables - sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl exec "$POD_NAME" -- sh -c " - export REGISTRY_USER='${REGISTRY_USER}' - export REGISTRY_PASS='${REGISTRY_PASS}' - export EXPOSED_HARBOR_PORT='${EXPOSED_HARBOR_PORT}' - chmod +x /tmp/setup-helm.sh - /tmp/setup-helm.sh - " - - - - name: 🚀 Deploy to Both Devices in Parallel - working-directory: pipeline - env: - MAESTRO_CLI_PATH: /home/runner/symphony/cli - run: | - echo "🚀 Deploying to both devices in parallel..." - - # Deploy to K3s in background - ( - echo "📋 Deploying to K3s..." - PACKAGE_ID=$(sudo -E bash ./wfm-cli.sh get-package-id-by-name "${APP_PACKAGE_NAME_K3S}" | tail -n1) - DEVICE_ID=$(sudo -E bash ./wfm-cli.sh get-device-id-by-role "Standalone Cluster" | tail -n1) - - if [ -z "$PACKAGE_ID" ] || [ "$PACKAGE_ID" = "null" ] || [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "null" ]; then - echo "❌ K3s: Invalid IDs" - exit 1 - fi - - DEPLOY_OUTPUT=$(sudo -E bash ./wfm-cli.sh deploy-non-interactive "$PACKAGE_ID" "$DEVICE_ID") - DEPLOYMENT_ID=$(echo "$DEPLOY_OUTPUT" | grep -oP 'deploymentId \K[a-f0-9-]+' | head -1) - echo "📦 K3s Deployment ID: $DEPLOYMENT_ID" - - # Monitor K3s deployment - K="sudo KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl" - for ATTEMPT in {1..60}; do - DEPLOYMENT_STATUS=$(sudo -E bash ./wfm-cli.sh list-deployments 2>/dev/null | grep "${DEPLOYMENT_ID:0:10}" | awk '{print $NF}' || echo "") - - if [ "$DEPLOYMENT_STATUS" = "INSTALLED" ]; then - echo "✅ K3s deployment completed!" - break - elif [ "$DEPLOYMENT_STATUS" = "FAILED" ] || [ "$DEPLOYMENT_STATUS" = "ERROR" ]; then - echo "❌ K3s deployment failed" - $K logs deployment/workload-fleet-management-client-deploy --tail=100 || true - exit 1 - fi - sleep 3 - done - ) & - K3S_DEPLOY_PID=$! - - # Deploy to Docker in background - ( - echo "📋 Deploying to Docker..." - PACKAGE_ID=$(sudo -E bash ./wfm-cli.sh get-package-id-by-name "${APP_PACKAGE_NAME_DOCKER}" | tail -n1) - DEVICE_ID=$(sudo -E bash ./wfm-cli.sh get-device-id-by-role "Standalone Device" | tail -n1) - - if [ -z "$PACKAGE_ID" ] || [ "$PACKAGE_ID" = "null" ] || [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "null" ]; then - echo "❌ Docker: Invalid IDs" - exit 1 - fi - - DEPLOY_OUTPUT=$(sudo -E bash ./wfm-cli.sh deploy-non-interactive "$PACKAGE_ID" "$DEVICE_ID") - DEPLOYMENT_ID=$(echo "$DEPLOY_OUTPUT" | grep -oP 'deploymentId \K[a-f0-9-]+' | head -1) - echo "📦 Docker Deployment ID: $DEPLOYMENT_ID" - - # Monitor Docker deployment - for ATTEMPT in {1..60}; do - DEPLOYMENT_STATUS=$(sudo -E bash ./wfm-cli.sh list-deployments 2>/dev/null | grep "${DEPLOYMENT_ID:0:10}" | awk '{print $NF}' || echo "") - - if [ "$DEPLOYMENT_STATUS" = "INSTALLED" ]; then - echo "✅ Docker deployment completed!" - break - elif [ "$DEPLOYMENT_STATUS" = "FAILED" ] || [ "$DEPLOYMENT_STATUS" = "ERROR" ]; then - echo "❌ Docker deployment failed" - docker logs workload-fleet-management-client --tail=100 || true - exit 1 - fi - sleep 3 - done - ) & - DOCKER_DEPLOY_PID=$! - - # Wait for both deployments - wait $K3S_DEPLOY_PID || exit 1 - wait $DOCKER_DEPLOY_PID || exit 1 - - echo "✅ Both deployments completed successfully!" - - - - name: 📋 Final Deployment Status - working-directory: pipeline - env: - MAESTRO_CLI_PATH: /home/runner/symphony/cli - run: | - echo "" - echo "=========================================" - echo "📋 FINAL DEPLOYMENT STATUS" - echo "=========================================" - sudo -E bash ./wfm-cli.sh list-all-non-interactive diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..9bda838e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,104 @@ +version: "2" + +linters: + # Default set of linters + # Options: standard, all, none, fast + default: standard + + # Enable specific linters + enable: + # Critical: Catch actual bugs + - errcheck # Unchecked errors (can hide bugs) + - govet # Official Go bug detector + - noctx # Missing context in function calls + - nilerr # Returning nil when error expected + # Important: Code quality & maintainability + - staticcheck # Common mistakes and bugs + # - unused # Unused code (variables, functions, imports) + - unconvert # Remove unnecessary type conversions + - reassign + # Security + - gosec # Security issues (SQL injection, hardcoded secrets, etc.) + disable: + - unused + + # Linter-specific settings + settings: + errcheck: + check-type-assertions: false + check-blank: false + # Ignore these across the entire project because they mostly are used in defer + # and rarely fail or the failure isn't critical (e.g. closing a read-only file) + exclude-functions: + - (*io.Closer).Close + - (*os.File).Close + - (io.Closer).Close + - (io.ReadCloser).Close + gosec: + config: + G104: + # Format is "package.Type": ["Method1", "Method2"] + - "io.Closer": + - Close + - "os.File": + - Close + - "io.ReadCloser": + - Close + + # Exclusion rules for linters + exclusions: + generated: lax + warn-unused: false + presets: [] + + rules: + # Tests can have unused variables and short error handling + - path: "_test\\.go$" + linters: + - unused + - errcheck + + # Mock files excluded from unused checks + - path: "mock_.*\\.go$" + linters: + - unused + + - path: . + linters: + - gosec + text: "G104:" + - path: . + linters: + - gosec + # skipping this as of now, as the code utilizes the docker cli to deploy compose files + # and the inputs are also not sanitized, will remove this once the docker sdk is completely + # tested + text: "G204:" + + paths: + - standard/generatedCode/* + - non-standard/generatedCode/* + +formatters: + enable: + - goimports # Organize imports (auto-fixable) + +output: + formats: + text: + path: stdout + print-linter-name: false + print-issued-lines: true + colors: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +run: + timeout: 5m + build-tags: + - integration + +severity: + default: error diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..822e7aaf --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,14 @@ +{ + "recommendations": [ + "golang.go", + "esbenp.prettier-vscode", + "redhat.vscode-yaml", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-azuretools.vscode-docker", + "foxundermoon.shell-format", + "editorconfig.editorconfig", + "ms-azuretools.vscode-helm", + "github.copilot", + "SanjulaGanepola.github-local-actions" + ] +} diff --git a/go.mod b/go.mod index 158bbae0..62915358 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/margo/sandbox go 1.24.4 -toolchain go1.24.7 - require ( github.com/compose-spec/compose-go/v2 v2.8.1 github.com/docker/cli v28.3.3+incompatible diff --git a/helmchart/templates/pvc.yaml b/helmchart/templates/pvc.yaml.tpl similarity index 100% rename from helmchart/templates/pvc.yaml rename to helmchart/templates/pvc.yaml.tpl diff --git a/helmchart/templates/rbac.yaml b/helmchart/templates/rbac.yaml.tpl similarity index 100% rename from helmchart/templates/rbac.yaml rename to helmchart/templates/rbac.yaml.tpl diff --git a/non-standard/pkg/packageManager/packageManager.go b/non-standard/pkg/packageManager/packageManager.go index 453e7e43..a60acbbc 100644 --- a/non-standard/pkg/packageManager/packageManager.go +++ b/non-standard/pkg/packageManager/packageManager.go @@ -3,19 +3,21 @@ package packageManager import ( "archive/tar" "compress/gzip" + "context" + "io" + //"context" "fmt" - "io" "os" "os/exec" "path/filepath" "strings" "time" - v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/margo/sandbox/non-standard/generatedCode/wfm/nbi" "github.com/margo/sandbox/non-standard/pkg/models" "github.com/margo/sandbox/shared-lib/git" + //"github.com/margo/sandbox/shared-lib/oci" "gopkg.in/yaml.v3" ) @@ -126,7 +128,7 @@ func (pm *PackageManager) LoadPackageFromGit(url, branchName, subPath string, au appPackage, err := pm.LoadPackageFromDir(dirPath) if err != nil { // Clean up on failure - os.RemoveAll(dirPath) + _ = os.RemoveAll(dirPath) return "", nil, fmt.Errorf("failed to load package from cloned repository: %w", err) } @@ -235,180 +237,68 @@ func (pm *PackageManager) LoadPackageFromGit(url, branchName, subPath string, au // LoadPackageFromOci loads an application package from an OCI registry. USING ORAS CLI. func (pm *PackageManager) LoadPackageFromOci(registryUrl, repository, tag string, username, passwordOrToken string, insecure bool, timeout time.Duration) (pkgPath string, pkg *models.AppPkg, err error) { - // Create temporary directory for extraction - tempDir, err := os.MkdirTemp("", "margo-oci-pkg-*") - if err != nil { - return "", nil, fmt.Errorf("failed to create temporary directory: %w", err) - } - - // Detect if the registry uses HTTP or HTTPS - isHTTP := strings.HasPrefix(registryUrl, "http://") - isHTTPS := strings.HasPrefix(registryUrl, "https://") - - // Strip protocol from registryUrl for ORAS compatibility - cleanRegistryUrl := strings.TrimPrefix(registryUrl, "http://") - cleanRegistryUrl = strings.TrimPrefix(cleanRegistryUrl, "https://") - - // Construct OCI reference (without protocol) - reference := fmt.Sprintf("%s/%s:%s", cleanRegistryUrl, repository, tag) - - // Add authentication if provided - if username != "" && passwordOrToken != "" { - // Build login command - loginArgs := []string{"login", cleanRegistryUrl, "-u", username, "-p", passwordOrToken} - - // Add appropriate flags based on protocol - if isHTTP { - loginArgs = append(loginArgs, "--plain-http") - } else if isHTTPS && insecure { - loginArgs = append(loginArgs, "--insecure") - } - - loginCmd := exec.Command("oras", loginArgs...) - if err := loginCmd.Run(); err != nil { - os.RemoveAll(tempDir) - return "", nil, fmt.Errorf("failed to login to OCI registry: %w", err) - } - } - - // Build pull command - pullArgs := []string{"pull", reference} - - // Add appropriate flags based on protocol - if isHTTP { - pullArgs = append(pullArgs, "--plain-http") - } else if isHTTPS && insecure { - pullArgs = append(pullArgs, "--insecure") - } - - pullCmd := exec.Command("oras", pullArgs...) - pullCmd.Dir = tempDir - output, err := pullCmd.CombinedOutput() - if err != nil { - os.RemoveAll(tempDir) - return "", nil, fmt.Errorf("failed to pull OCI artifact: %w, output: %s", err, string(output)) - } - - // Load package from extracted directory - appPackage, err := pm.LoadPackageFromDir(tempDir) - if err != nil { - os.RemoveAll(tempDir) - return "", nil, fmt.Errorf("failed to load package from extracted OCI artifact: %w", err) - } - - return tempDir, appPackage, nil -} - - -// extractImageToDir extracts all layers of an OCI image to a directory. -// -// This method processes each layer of an OCI image sequentially, extracting -// the tar archive contents to the destination directory. It handles directories, -// regular files, and symbolic links, preserving file permissions and structure. -// -// Parameters: -// - image: The OCI image to extract -// - destDir: The destination directory where contents should be extracted -// -// Returns: -// - error: An error if layer extraction or file writing fails -// -// Extraction behavior: -// - Processes layers in order (later layers can overwrite earlier ones) -// - Creates directories with original permissions -// - Writes regular files with original permissions -// - Creates symbolic links preserving link targets -// - Skips special file types (block devices, character devices, etc.) -// -// Example: -// -// err := extractImageToDir(image, "/tmp/extracted-package") -// if err != nil { -// log.Fatal("Failed to extract image:", err) -// } -// -// Errors: -// - Returns error if image layers cannot be accessed -// - Returns error if layer decompression fails -// - Returns error if tar reading fails -// - Returns error if directory creation fails -// - Returns error if file writing fails -func extractImageToDir(image v1.Image, destDir string) error { - // Get image layers - layers, err := image.Layers() + // Create temporary directory for extraction + tempDir, err := os.MkdirTemp("", "margo-oci-pkg-*") if err != nil { - return fmt.Errorf("failed to get image layers: %w", err) + return "", nil, fmt.Errorf("failed to create temporary directory: %w", err) } - // Extract each layer - for i, layer := range layers { - // Get uncompressed layer content - layerReader, err := layer.Uncompressed() - if err != nil { - return fmt.Errorf("failed to get uncompressed layer %d: %w", i, err) - } - defer layerReader.Close() + // Detect if the registry uses HTTP or HTTPS + isHTTP := strings.HasPrefix(registryUrl, "http://") + isHTTPS := strings.HasPrefix(registryUrl, "https://") - // Create tar reader - tarReader := tar.NewReader(layerReader) + // Strip protocol from registryUrl for ORAS compatibility + cleanRegistryUrl := strings.TrimPrefix(registryUrl, "http://") + cleanRegistryUrl = strings.TrimPrefix(cleanRegistryUrl, "https://") - // Extract all files from the layer - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("failed to read tar header in layer %d: %w", i, err) - } + // Construct OCI reference (without protocol) + reference := fmt.Sprintf("%s/%s:%s", cleanRegistryUrl, repository, tag) - // Construct target path - targetPath := filepath.Join(destDir, header.Name) - - // Handle different file types - switch header.Typeflag { - case tar.TypeDir: - // Create directory - if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { - return fmt.Errorf("failed to create directory %s: %w", targetPath, err) - } - - case tar.TypeReg: - // Create parent directory if needed - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err) - } - - // Create and write file - outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", targetPath, err) - } - - if _, err := io.Copy(outFile, tarReader); err != nil { - outFile.Close() - return fmt.Errorf("failed to write file %s: %w", targetPath, err) - } - outFile.Close() - - case tar.TypeSymlink: - // Create parent directory if needed - if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return fmt.Errorf("failed to create parent directory for symlink %s: %w", targetPath, err) - } - - // Create symlink - if err := os.Symlink(header.Linkname, targetPath); err != nil { - return fmt.Errorf("failed to create symlink %s: %w", targetPath, err) - } - - default: - // Skip other types (block devices, character devices, etc.) - continue - } + // Add authentication if provided + if username != "" && passwordOrToken != "" { + // Build login command + loginArgs := []string{"login", cleanRegistryUrl, "-u", username, "-p", passwordOrToken} + + // Add appropriate flags based on protocol + if isHTTP { + loginArgs = append(loginArgs, "--plain-http") + } else if isHTTPS && insecure { + loginArgs = append(loginArgs, "--insecure") + } + + loginCmd := exec.CommandContext(context.Background(), "oras", loginArgs...) + if err := loginCmd.Run(); err != nil { + _ = os.RemoveAll(tempDir) + return "", nil, fmt.Errorf("failed to login to OCI registry: %w", err) } } - return nil + + // Build pull command + pullArgs := []string{"pull", reference} + + // Add appropriate flags based on protocol + if isHTTP { + pullArgs = append(pullArgs, "--plain-http") + } else if isHTTPS && insecure { + pullArgs = append(pullArgs, "--insecure") + } + + pullCmd := exec.CommandContext(context.Background(), "oras", pullArgs...) + pullCmd.Dir = tempDir + output, err := pullCmd.CombinedOutput() + if err != nil { + _ = os.RemoveAll(tempDir) + return "", nil, fmt.Errorf("failed to pull OCI artifact: %w, output: %s", err, string(output)) + } + + // Load package from extracted directory + appPackage, err := pm.LoadPackageFromDir(tempDir) + if err != nil { + _ = os.RemoveAll(tempDir) + return "", nil, fmt.Errorf("failed to load package from extracted OCI artifact: %w", err) + } + + return tempDir, appPackage, nil } // LoadPackageFromDir loads an application package from a local directory. @@ -573,7 +463,7 @@ func (pm *PackageManager) findAppDescription(pkgPath string) (string, error) { // to allow graceful handling during file discovery. func (pm *PackageManager) isValidAppDescription(filePath string) bool { // Read file contents - data, err := os.ReadFile(filePath) + data, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { return false } @@ -626,11 +516,13 @@ func (pm *PackageManager) isValidAppDescription(filePath string) bool { // - Future: Will return validation errors for missing required fields func (pm *PackageManager) loadAppDescription(filePath string) (*nbi.AppDescription, error) { // Open file for reading - reader, err := os.Open(filePath) + reader, err := os.Open(filepath.Clean(filePath)) if err != nil { return nil, fmt.Errorf("failed to open application description file %s: %w", filePath, err) } - defer reader.Close() + defer func() { + _ = reader.Close() + }() // Parse application description using models package desc, err := models.ParseApplicationDescription(reader, models.ApplicationDescriptionFormatYAML) @@ -682,6 +574,15 @@ func (pm *PackageManager) loadAppDescription(filePath string) (*nbi.AppDescripti // - Returns error if any file cannot be read // - Returns error if relative path calculation fails func (pm *PackageManager) loadAppResources(resourcesPath string, resources map[string][]byte) error { + root, err := os.OpenRoot(filepath.Clean(resourcesPath)) + if err != nil { + return err + } + defer func() { + _ = root.Close() + }() + + // Use WalkDir to find files, but use root.Open to access them return filepath.Walk(resourcesPath, func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("failed to access path %s: %w", path, err) @@ -698,11 +599,19 @@ func (pm *PackageManager) loadAppResources(resourcesPath string, resources map[s return fmt.Errorf("failed to calculate relative path for %s: %w", path, err) } - // Read file content - content, err := os.ReadFile(path) + f, err := root.Open(filepath.Clean(path)) if err != nil { return fmt.Errorf("failed to read resource file %s: %w", path, err) } + defer func() { + _ = f.Close() + }() + + // Read file content using the file handle + content, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("failed to read resource file %s: %w", relPath, err) + } // Store resource with relative path as key resources[relPath] = content @@ -761,7 +670,7 @@ func (pm *PackageManager) loadAppResources(resourcesPath string, resources map[s // - Returns error if any resource file cannot be written func (pm *PackageManager) CreatePackage(desc nbi.AppDescription, resources map[string][]byte, outputPath string) error { // Create package directory - if err := os.MkdirAll(outputPath, 0755); err != nil { + if err := os.MkdirAll(outputPath, 0750); err != nil { return fmt.Errorf("failed to create package directory %s: %w", outputPath, err) } @@ -772,14 +681,14 @@ func (pm *PackageManager) CreatePackage(desc nbi.AppDescription, resources map[s } descFile := filepath.Join(outputPath, ExpectedApplicationDescriptionFileName) - if err := os.WriteFile(descFile, descData, 0644); err != nil { + if err := os.WriteFile(descFile, descData, 0600); err != nil { return fmt.Errorf("failed to write application description to %s: %w", descFile, err) } // Create resources directory and files if resources are provided if len(resources) > 0 { resourcesDir := filepath.Join(outputPath, "resources") - if err := os.MkdirAll(resourcesDir, 0755); err != nil { + if err := os.MkdirAll(resourcesDir, 0750); err != nil { return fmt.Errorf("failed to create resources directory %s: %w", resourcesDir, err) } @@ -789,11 +698,11 @@ func (pm *PackageManager) CreatePackage(desc nbi.AppDescription, resources map[s // Create subdirectories if needed resourceDir := filepath.Dir(resourcePath) - if err := os.MkdirAll(resourceDir, 0755); err != nil { + if err := os.MkdirAll(resourceDir, 0750); err != nil { return fmt.Errorf("failed to create resource subdirectory %s: %w", resourceDir, err) } - if err := os.WriteFile(resourcePath, content, 0644); err != nil { + if err := os.WriteFile(resourcePath, content, 0600); err != nil { return fmt.Errorf("failed to write resource file %s: %w", filename, err) } } @@ -847,19 +756,25 @@ func (pm *PackageManager) CreatePackage(desc nbi.AppDescription, resources map[s // Note: The caller should ensure the output directory exists and is writable. func (pm *PackageManager) PackageToTarball(pkg *models.AppPkg, outputPath string) error { // Create output file - file, err := os.Create(outputPath) + file, err := os.Create(filepath.Clean(outputPath)) if err != nil { return fmt.Errorf("failed to create tarball file %s: %w", outputPath, err) } - defer file.Close() + defer func() { + _ = file.Close() + }() // Create gzip writer gzWriter := gzip.NewWriter(file) - defer gzWriter.Close() + defer func() { + _ = gzWriter.Close() + }() // Create tar writer tarWriter := tar.NewWriter(gzWriter) - defer tarWriter.Close() + defer func() { + _ = tarWriter.Close() + }() // Add application description descData, err := yaml.Marshal(pkg.Description) @@ -900,7 +815,3 @@ func (pm *PackageManager) PackageToTarball(pkg *models.AppPkg, outputPath string return nil } - -func (pm *PackageManager) checkPkgUpdates(pkg *models.AppPkg) error { - return nil -} diff --git a/non-standard/pkg/packageManager/packageManager_test.go b/non-standard/pkg/packageManager/packageManager_test.go deleted file mode 100644 index 7d4954c3..00000000 --- a/non-standard/pkg/packageManager/packageManager_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package packageManager - -import ( - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestLoadPackageFromOci_Success tests successful package loading from OCI registry -// Note: This test requires a real OCI registry or mock implementation -// TODO: Introduce mock OCI registry for isolated testing -func TestLoadPackageFromOci_Success(t *testing.T) { - t.Skip("Skipping integration test - requires OCI registry setup or mocks") - - pm := NewPackageManager() - pkgPath, pkg, err := pm.LoadPackageFromOci( - "docker.io", - "testuser/testapp", - "v1.0.0", - "testuser", - "testtoken", - false, - time.Second*30, - ) - - require.NoError(t, err) - require.NotNil(t, pkg) - require.NotEmpty(t, pkgPath) - defer os.RemoveAll(pkgPath) // Cleanup temporary directory - - // Verify package path exists - _, err = os.Stat(pkgPath) - assert.NoError(t, err, "package path should exist") - - // Verify package content - assert.NotNil(t, pkg.Description) - assert.NotEmpty(t, pkg.Description.Metadata.Name) - assert.NotEmpty(t, pkg.Description.Metadata.Version) -} - -// TestLoadPackageFromOci_InvalidRegistry tests error handling for invalid registry -func TestLoadPackageFromOci_InvalidRegistry(t *testing.T) { - pm := NewPackageManager() - pkgPath, pkg, err := pm.LoadPackageFromOci( - "", - "testuser/testapp", - "v1.0.0", - "", - "", - false, - time.Second*30, - ) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to initialize OCI client") - assert.Empty(t, pkgPath) - assert.Nil(t, pkg) -} - -// TestLoadPackageFromOci_CleanupOnFailure tests that temp directories are cleaned up on failure -// Note: Mock OCI client can be introduced here to simulate pull failures -// TODO: Add mock to test cleanup behavior without external dependencies -func TestLoadPackageFromOci_CleanupOnFailure(t *testing.T) { - t.Skip("Skipping - requires mock OCI client to simulate failures") - - pm := NewPackageManager() - pkgPath, pkg, err := pm.LoadPackageFromOci( - "docker.io", - "nonexistent/repo", - "nonexistent", - "", - "", - false, - time.Second*30, - ) - - require.Error(t, err) - assert.Empty(t, pkgPath) - assert.Nil(t, pkg) - - // Verify no temporary directories are left behind - // This would be properly tested with mocks -} diff --git a/poc/device/agent/Dockerfile b/poc/device/agent/Dockerfile index fac66055..a027fa1c 100644 --- a/poc/device/agent/Dockerfile +++ b/poc/device/agent/Dockerfile @@ -1,6 +1,10 @@ # Stage 1: Build FROM golang:1.24.4 AS builder +ARG VERSION +ARG COMMIT +ARG DATE + WORKDIR /app COPY go.mod go.sum ./ @@ -9,8 +13,10 @@ RUN go mod download COPY . . # Build a static binary - -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=tobedone" -o device-agent ./poc/device/agent +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-X main.build=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o device-agent ./poc/device/agent # Stage 2: Get Docker binary and compose plugin from official image FROM docker:28.3.3-cli AS docker-source @@ -25,6 +31,7 @@ WORKDIR / COPY --from=builder /app/device-agent . COPY --from=builder /app/poc/device/agent/config ./config +# this will remove the dependency from docker image COPY --from=docker-source /usr/local/bin/docker /usr/bin/docker # Create the cli-plugins directory FIRST @@ -39,11 +46,6 @@ RUN chmod +x /usr/local/libexec/docker/cli-plugins/docker-compose # Create volume for config override VOLUME ["/config"] -# Add metadata labels -LABEL description="Margo Device Workload Fleet management Client for edge workload management" \ - source="https://github.com/margo/sandbox" \ - title="Device Workload Fleet management Client" - # Use ENTRYPOINT for better flexibility ENTRYPOINT ["./device-agent"] CMD ["-config", "config/config.yaml"] diff --git a/poc/device/agent/database/database.go b/poc/device/agent/database/database.go index daef450f..a514cca3 100644 --- a/poc/device/agent/database/database.go +++ b/poc/device/agent/database/database.go @@ -121,7 +121,7 @@ func (db *Database) GetLastSyncedETag() (string, error) { defer db.mu.RUnlock() if db.deviceSettings.LastSyncedETag == "" { - return "", fmt.Errorf("No previous ETag found") + return "", fmt.Errorf("no previous ETag found") } return db.deviceSettings.LastSyncedETag, nil } @@ -235,20 +235,25 @@ func (db *Database) save() { return } - os.MkdirAll(db.dataDir, 0755) + if err := os.MkdirAll(db.dataDir, 0750); err != nil { + return + } + tempFile := filepath.Join(db.dataDir, "agent.database.json.tmp") finalFile := filepath.Join(db.dataDir, "agent.database.json") - if err := os.WriteFile(tempFile, data, 0644); err != nil { + if err := os.WriteFile(tempFile, data, 0600); err != nil { return } - os.Rename(tempFile, finalFile) // Atomic + if err := os.Rename(tempFile, finalFile); err != nil { + return + } } func (db *Database) load() { file := filepath.Join(db.dataDir, "agent.database.json") - data, err := os.ReadFile(file) + data, err := os.ReadFile(filepath.Clean(file)) if err != nil { return // File doesn't exist, start fresh } @@ -358,9 +363,10 @@ func (db *Database) SetComponentStatus(deploymentId, componentName string, statu record.LastUpdated = time.Now() // Update overall phase based on component status - if status.State == sbi.ComponentStatusStateInstalled { + switch status.State { + case sbi.ComponentStatusStateInstalled: record.Phase = "running" - } else if status.State == sbi.ComponentStatusStateFailed { + case sbi.ComponentStatusStateFailed: record.Phase = "failed" } @@ -429,8 +435,8 @@ func (db *Database) NeedsReconciliation(deploymentId string) bool { } // Compare the embedded AppDeploymentManifest specs by marshaling to JSON - currentSpecBytes, err1 := json.Marshal(record.CurrentState.AppDeploymentManifest.Spec) - desiredSpecBytes, err2 := json.Marshal(record.DesiredState.AppDeploymentManifest.Spec) + currentSpecBytes, err1 := json.Marshal(record.CurrentState.Spec) + desiredSpecBytes, err2 := json.Marshal(record.DesiredState.Spec) if err1 != nil || err2 != nil { // If marshaling fails, assume reconciliation is needed diff --git a/poc/device/agent/deployment.go b/poc/device/agent/deployment.go index f0af8a66..537ba7fa 100644 --- a/poc/device/agent/deployment.go +++ b/poc/device/agent/deployment.go @@ -207,7 +207,7 @@ func (dm *DeploymentManager) deployOrUpdate(ctx context.Context, deploymentId st case sbi.HelmV3: // Check if Helm client is available if dm.helmClient == nil { - err = fmt.Errorf("Helm client not initialized (device may not support Helm deployments)") + err = fmt.Errorf("helm client not initialized (device may not support Helm deployments)") } else { err = dm.deployOrUpdateHelm(ctx, deploymentId, appDeployment) } @@ -215,7 +215,7 @@ func (dm *DeploymentManager) deployOrUpdate(ctx context.Context, deploymentId st case sbi.Compose: // Check if Compose client is available if dm.composeClient == nil { - err = fmt.Errorf("Docker Compose client not initialized (device may not support Compose deployments)") + err = fmt.Errorf("docker Compose client not initialized (device may not support Compose deployments)") } else { err = dm.deployOrUpdateCompose(ctx, deploymentId, appDeployment) } @@ -509,81 +509,79 @@ func (dm *DeploymentManager) remove(ctx context.Context, deploymentId string) { } func (dm *DeploymentManager) removeHelm(ctx context.Context, deploymentId string, appDeployment sbi.AppDeploymentManifest) error { - if dm.helmClient == nil { - dm.log.Warnw("Helm client not initialized, skipping Helm removal", "deploymentId", deploymentId) - return nil - } - - for _, component := range appDeployment.Spec.DeploymentProfile.Components { - helmComp, err := component.AsHelmApplicationDeploymentProfileComponent() - if err != nil { - dm.log.Warnw("Failed to parse helm component during removal", "error", err) - continue // ✅ Continue to next component - } - - releaseName := fmt.Sprintf("%s-%s", helmComp.Name, deploymentId[:8]) - dm.log.Infow("Removing Helm release", - "releaseName", releaseName, - "componentName", helmComp.Name, - "deploymentId", deploymentId) - - if err := dm.helmClient.UninstallChart(ctx, releaseName, ""); err != nil { - dm.log.Warnw("Failed to uninstall Helm chart", - "releaseName", releaseName, - "componentName", helmComp.Name, - "error", err) - // ✅ Continue removing other components - } else { - dm.log.Infow("Helm release removed successfully", - "releaseName", releaseName, - "componentName", helmComp.Name) - } - } - - return nil // ✅ All components processed -} + if dm.helmClient == nil { + dm.log.Warnw("Helm client not initialized, skipping Helm removal", "deploymentId", deploymentId) + return nil + } + for _, component := range appDeployment.Spec.DeploymentProfile.Components { + helmComp, err := component.AsHelmApplicationDeploymentProfileComponent() + if err != nil { + dm.log.Warnw("Failed to parse helm component during removal", "error", err) + continue // ✅ Continue to next component + } -func (dm *DeploymentManager) removeCompose(ctx context.Context, deploymentId string, appDeployment sbi.AppDeploymentManifest) error { - // Check if Compose client is available - if dm.composeClient == nil { - dm.log.Warnw("Docker Compose client not initialized, skipping Compose removal", "deploymentId", deploymentId) - return nil - } - - // Iterate through ALL components (matching deployOrUpdateCompose pattern) - for _, component := range appDeployment.Spec.DeploymentProfile.Components { - composeComp, err := component.AsComposeApplicationDeploymentProfileComponent() - if err != nil { - dm.log.Warnw("Failed to parse compose component during removal", "error", err) - continue // Continue removing other components even if one fails to parse - } - - // Generate project name (same logic as deployment) - projectName := fmt.Sprintf("%s-%s", strings.ToLower(composeComp.Name), deploymentId[:8]) - projectName = strings.ReplaceAll(projectName, "_", "-") - - dm.log.Infow("Removing Docker Compose project", - "projectName", projectName, - "componentName", composeComp.Name, - "deploymentId", deploymentId) - - if err := dm.composeClient.RemoveCompose(ctx, projectName); err != nil { - dm.log.Warnw("Failed to remove Docker Compose project", - "projectName", projectName, - "componentName", composeComp.Name, - "error", err) - // Continue removing other components even if one fails - } else { - dm.log.Infow("Docker Compose project removed successfully", - "projectName", projectName, - "componentName", composeComp.Name) - } - } - - return nil + releaseName := fmt.Sprintf("%s-%s", helmComp.Name, deploymentId[:8]) + dm.log.Infow("Removing Helm release", + "releaseName", releaseName, + "componentName", helmComp.Name, + "deploymentId", deploymentId) + + if err := dm.helmClient.UninstallChart(ctx, releaseName, ""); err != nil { + dm.log.Warnw("Failed to uninstall Helm chart", + "releaseName", releaseName, + "componentName", helmComp.Name, + "error", err) + // ✅ Continue removing other components + } else { + dm.log.Infow("Helm release removed successfully", + "releaseName", releaseName, + "componentName", helmComp.Name) + } + } + + return nil // ✅ All components processed } +func (dm *DeploymentManager) removeCompose(ctx context.Context, deploymentId string, appDeployment sbi.AppDeploymentManifest) error { + // Check if Compose client is available + if dm.composeClient == nil { + dm.log.Warnw("Docker Compose client not initialized, skipping Compose removal", "deploymentId", deploymentId) + return nil + } + + // Iterate through ALL components (matching deployOrUpdateCompose pattern) + for _, component := range appDeployment.Spec.DeploymentProfile.Components { + composeComp, err := component.AsComposeApplicationDeploymentProfileComponent() + if err != nil { + dm.log.Warnw("Failed to parse compose component during removal", "error", err) + continue // Continue removing other components even if one fails to parse + } + + // Generate project name (same logic as deployment) + projectName := fmt.Sprintf("%s-%s", strings.ToLower(composeComp.Name), deploymentId[:8]) + projectName = strings.ReplaceAll(projectName, "_", "-") + + dm.log.Infow("Removing Docker Compose project", + "projectName", projectName, + "componentName", composeComp.Name, + "deploymentId", deploymentId) + + if err := dm.composeClient.RemoveCompose(ctx, projectName); err != nil { + dm.log.Warnw("Failed to remove Docker Compose project", + "projectName", projectName, + "componentName", composeComp.Name, + "error", err) + // Continue removing other components even if one fails + } else { + dm.log.Infow("Docker Compose project removed successfully", + "projectName", projectName, + "componentName", composeComp.Name) + } + } + + return nil +} // extractComponentNames returns the name of every component in an AppDeploymentManifest, // regardless of the deployment profile type (Helm, Compose, etc.). @@ -624,4 +622,4 @@ func (dm *DeploymentManager) convertParametersToEnvVars(params map[string]interf } return envVars -} \ No newline at end of file +} diff --git a/poc/device/agent/main.go b/poc/device/agent/main.go index d6efa545..88098d61 100644 --- a/poc/device/agent/main.go +++ b/poc/device/agent/main.go @@ -132,7 +132,7 @@ func NewAgent(configPath string) (*Agent, error) { return nil, fmt.Errorf("neither kubernetes nor docker runtime objects were able to be attached, please check info if you have misplaced their settings") } - opts = append(opts, WithDeviceRootIdentity(findDeviceRootIdentity(*cfg, log))) + opts = append(opts, WithDeviceRootIdentity(findDeviceRootIdentity(*cfg))) var deviceSettings *DeviceClientSettings deviceSettings, err = NewDeviceSettings(wfmClient, db, log, opts...) @@ -209,7 +209,10 @@ func (a *Agent) Start() error { var err error // 1. Onboard device - deviceSettings, _ := a.database.GetDeviceSettings() + deviceSettings, err := a.database.GetDeviceSettings() + if err != nil { + return err + } deviceId = deviceSettings.DeviceClientId // 2. Report capabilities @@ -223,8 +226,10 @@ func (a *Agent) Start() error { } else { capabilities.Properties.Id = deviceId ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - a.auth.ReportCapabilities(ctx, *capabilities) - cancel() + defer cancel() + if err := a.auth.ReportCapabilities(ctx, *capabilities); err != nil { + a.log.Errorw("failed to report the capabilities, ", "err", err.Error()) + } } // 3. Start all components @@ -233,10 +238,7 @@ func (a *Agent) Start() error { a.monitor.Start() a.syncer.Start() - hasCfgPubCert := false - if a.config.DeviceRootIdentity.HasCertificateReference() { - hasCfgPubCert = true - } + hasCfgPubCert := a.config.DeviceRootIdentity.HasCertificateReference() a.log.Infow("Workload Fleet Management Client started successfully", "capabilitiesFile", a.config.Capabilities.ReadFromFile, @@ -260,7 +262,7 @@ func (a *Agent) Stop() error { return nil } -func findDeviceRootIdentity(cfg types.Config, logger *zap.SugaredLogger) types.DeviceRootIdentity { +func findDeviceRootIdentity(cfg types.Config) types.DeviceRootIdentity { return cfg.DeviceRootIdentity } @@ -298,7 +300,10 @@ func main() { signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan - agent.Stop() + if err := agent.Stop(); err != nil { + log.Println("error occured while stopping the agent", err) + } + log.Println("agent stopped successfully!") } // PreflightLogger returns a RequestEditorFn that logs method, URL, headers (redacted) diff --git a/poc/device/agent/onboarding.go b/poc/device/agent/onboarding.go index d5cfd3b8..fc49ab50 100644 --- a/poc/device/agent/onboarding.go +++ b/poc/device/agent/onboarding.go @@ -142,7 +142,7 @@ func (da *DeviceClientSettings) Onboard(ctx context.Context) (deviceClientId str da.oauthTokenUrl = "" da.log.Infow("Device onboarding successful", "deviceClientId", da.deviceClientId) - da.db.SetDeviceSettings(database.DeviceSettingsRecord{ + err = da.db.SetDeviceSettings(database.DeviceSettingsRecord{ DeviceClientId: da.deviceClientId, DeviceRootIdentity: da.deviceRootIdentity, State: types.DeviceOnboardStateOnboarded, @@ -154,17 +154,14 @@ func (da *DeviceClientSettings) Onboard(ctx context.Context) (deviceClientId str CanDeployCompose: da.canDeployCompose, }) - return da.deviceClientId, nil + return da.deviceClientId, err } func (da *DeviceClientSettings) OnboardWithRetries(ctx context.Context, retries uint8) (deviceClientId string, err error) { totalRetries := retries ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() - for { - if retries == 0 { - break - } + for retries > 0 { retries-- // Wait for next tick or overall timeout diff --git a/poc/device/agent/stateSync.go b/poc/device/agent/stateSync.go index 081d9c13..354bf7d7 100644 --- a/poc/device/agent/stateSync.go +++ b/poc/device/agent/stateSync.go @@ -239,16 +239,6 @@ func (ss *StateSyncer) getLastSyncedETag() string { return etag } -// getLastSyncedManifestVersion retrieves the manifest version from last successful sync -func (ss *StateSyncer) getLastSyncedManifestVersion() uint64 { - version, err := ss.database.GetLastSyncedManifestVersion() - if err != nil { - ss.log.Debugw("No previous manifest version found", "error", err) - return 0 - } - return version -} - // persistManifestMetadata stores manifest metadata according to specification func (ss *StateSyncer) persistManifestMetadata(manifest *sbi.UnsignedAppStateManifest, response *http.Response) error { // Store manifest version for rollback protection @@ -474,7 +464,7 @@ func (ss *StateSyncer) processDeploymentsIndividually(ctx context.Context, deplo // processDeploymentsFromBundle processes deployments extracted from bundle -func (ss *StateSyncer) processDeploymentsFromBundle(ctx context.Context, deploymentRefs []sbi.DeploymentManifestRef, bundleYAMLs map[string][]byte) { +func (ss *StateSyncer) processDeploymentsFromBundle(_ context.Context, deploymentRefs []sbi.DeploymentManifestRef, bundleYAMLs map[string][]byte) { for _, deploymentRef := range deploymentRefs { if deploymentRef.DeploymentId == "" { ss.log.Warnw("Skipping deployment with empty DeploymentId") diff --git a/poc/device/agent/types/config.go b/poc/device/agent/types/config.go index 33799229..3f4f299e 100644 --- a/poc/device/agent/types/config.go +++ b/poc/device/agent/types/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "github.com/go-playground/validator/v10" "github.com/margo/sandbox/standard/generatedCode/wfm/sbi" @@ -128,7 +129,7 @@ type RuntimeInfo struct { } func LoadConfig(configPath string) (*Config, error) { - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(filepath.Clean(configPath)) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } @@ -144,7 +145,7 @@ func LoadConfig(configPath string) (*Config, error) { } func LoadCapabilities(capabilitiesPath string) (*sbi.DeviceCapabilitiesManifest, error) { - data, err := os.ReadFile(capabilitiesPath) + data, err := os.ReadFile(filepath.Clean(capabilitiesPath)) if err != nil { return nil, fmt.Errorf("failed to read capabilities file: %w", err) } diff --git a/poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/deployment.yaml b/poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/deployment.yaml.tpl similarity index 100% rename from poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/deployment.yaml rename to poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/deployment.yaml.tpl diff --git a/poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/service.yaml b/poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/service.yaml.tpl similarity index 100% rename from poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/service.yaml rename to poc/tests/artefacts/custom-otel-helm-app/code/helm/templates/service.yaml.tpl diff --git a/poc/wfm/cli/nbi.go b/poc/wfm/cli/nbi.go index 2049796b..54d60d46 100644 --- a/poc/wfm/cli/nbi.go +++ b/poc/wfm/cli/nbi.go @@ -10,11 +10,12 @@ import ( "context" "crypto/tls" "fmt" - nonStdWfmNbi "github.com/margo/sandbox/non-standard/generatedCode/wfm/nbi" "io" "log" "net/http" "time" + + nonStdWfmNbi "github.com/margo/sandbox/non-standard/generatedCode/wfm/nbi" ) const ( @@ -70,7 +71,7 @@ func WithInsecureTLS() WFMCliOption { cli.httpClient = &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, // Only for development + InsecureSkipVerify: true, // #nosec G402 -- intentional and safe skipping }, }, Timeout: cli.timeout, diff --git a/poc/wfm/cli/sbi.go b/poc/wfm/cli/sbi.go index e4369717..66d8eba2 100644 --- a/poc/wfm/cli/sbi.go +++ b/poc/wfm/cli/sbi.go @@ -41,7 +41,9 @@ func NewSbiHTTPClient(url string, options ...HTTPApiClientOptions) (*SbiHttpClie return nil, fmt.Errorf("failed to create API client: %w", err) } for _, opt := range options { - opt(client) + if err := opt(client); err != nil { + return nil, err + } } // Initialize caches @@ -65,8 +67,8 @@ func NewSbiHTTPClient(url string, options ...HTTPApiClientOptions) (*SbiHttpClie return apiClient, nil } -func (self *SbiHttpClient) OnboardDeviceClient(ctx context.Context, deviceCertificate []byte, overrideOptions ...HTTPApiClientRequestEditorOptions) (clientId string, endpoints []string, err error) { - cert := base64.StdEncoding.EncodeToString([]byte(deviceCertificate)) +func (sbiClient *SbiHttpClient) OnboardDeviceClient(ctx context.Context, deviceCertificate []byte, overrideOptions ...HTTPApiClientRequestEditorOptions) (clientId string, endpoints []string, err error) { + cert := base64.StdEncoding.EncodeToString(deviceCertificate) onboardingReq := sbi.PostApiV1OnboardingJSONRequestBody{ ApiVersion: "onboarding.margo.org/v1alpha1", @@ -74,7 +76,7 @@ func (self *SbiHttpClient) OnboardDeviceClient(ctx context.Context, deviceCertif Certificate: cert, } - resp, err := self.client.PostApiV1Onboarding(ctx, onboardingReq, overrideOptions...) + resp, err := sbiClient.client.PostApiV1Onboarding(ctx, onboardingReq, overrideOptions...) if err != nil { return "", nil, fmt.Errorf("onboarding failed: %w", err) } @@ -98,15 +100,15 @@ func (self *SbiHttpClient) OnboardDeviceClient(ctx context.Context, deviceCertif } if *onboardingResp.JSON201.ClientId == "" { - return "", nil, fmt.Errorf("the clientid is empty in the onboarding response, this should never happen!") + return "", nil, fmt.Errorf("the clientid is empty in the onboarding response, this should never happen") } var endpointsList []string return *onboardingResp.JSON201.ClientId, endpointsList, nil } -func (self *SbiHttpClient) ReportCapabilities(ctx context.Context, deviceClientId string, capabilities sbi.DeviceCapabilitiesManifest, overrideOptions ...HTTPApiClientRequestEditorOptions) error { - resp, err := self.client.PostApiV1ClientsClientIdCapabilities(ctx, deviceClientId, capabilities) +func (sbiClient *SbiHttpClient) ReportCapabilities(ctx context.Context, deviceClientId string, capabilities sbi.DeviceCapabilitiesManifest, overrideOptions ...HTTPApiClientRequestEditorOptions) error { + resp, err := sbiClient.client.PostApiV1ClientsClientIdCapabilities(ctx, deviceClientId, capabilities) if err != nil { return fmt.Errorf("failed to report capabilities: %w", err) } @@ -119,7 +121,7 @@ func (self *SbiHttpClient) ReportCapabilities(ctx context.Context, deviceClientI return nil } -func (self *SbiHttpClient) SyncState(ctx context.Context, deviceClientId string, etag string, overrideOptions ...HTTPApiClientRequestEditorOptions) (desiredStates *sbi.UnsignedAppStateManifest, err error) { +func (sbiClient *SbiHttpClient) SyncState(ctx context.Context, deviceClientId string, etag string, overrideOptions ...HTTPApiClientRequestEditorOptions) (desiredStates *sbi.UnsignedAppStateManifest, err error) { // Prepare parameters params := &sbi.GetApiV1ClientsClientIdDeploymentsParams{ Accept: pointers.Ptr("application/vnd.margo.manifest.v1+json"), @@ -130,7 +132,7 @@ func (self *SbiHttpClient) SyncState(ctx context.Context, deviceClientId string, params.IfNoneMatch = &etag } - resp, err := self.client.GetApiV1ClientsClientIdDeployments( + resp, err := sbiClient.client.GetApiV1ClientsClientIdDeployments( ctx, deviceClientId, params, @@ -170,7 +172,7 @@ func (self *SbiHttpClient) SyncState(ctx context.Context, deviceClientId string, } // SyncStateWithResponse retrieves the desired state manifest and returns the HTTP response for header access -func (self *SbiHttpClient) SyncStateWithResponse(ctx context.Context, deviceClientId string, etag string, overrideOptions ...HTTPApiClientRequestEditorOptions) (desiredStates *sbi.UnsignedAppStateManifest, response *http.Response, err error) { +func (sbiClient *SbiHttpClient) SyncStateWithResponse(ctx context.Context, deviceClientId string, etag string, overrideOptions ...HTTPApiClientRequestEditorOptions) (desiredStates *sbi.UnsignedAppStateManifest, response *http.Response, err error) { // Prepare parameters params := &sbi.GetApiV1ClientsClientIdDeploymentsParams{ Accept: pointers.Ptr("application/vnd.margo.manifest.v1+json"), @@ -181,7 +183,7 @@ func (self *SbiHttpClient) SyncStateWithResponse(ctx context.Context, deviceClie params.IfNoneMatch = &etag } - resp, err := self.client.GetApiV1ClientsClientIdDeployments( + resp, err := sbiClient.client.GetApiV1ClientsClientIdDeployments( ctx, deviceClientId, params, @@ -225,7 +227,7 @@ func (self *SbiHttpClient) SyncStateWithResponse(ctx context.Context, deviceClie } } -func (self *SbiHttpClient) ReportDeploymentStatus(ctx context.Context, deviceID, appID string, overallAppStatus sbi.DeploymentStatusManifestStatusState, components []sbi.ComponentStatus, deploymentErr error) error { +func (sbiClient *SbiHttpClient) ReportDeploymentStatus(ctx context.Context, deviceID, appID string, overallAppStatus sbi.DeploymentStatusManifestStatusState, components []sbi.ComponentStatus, deploymentErr error) error { appUUID, err := uuid.Parse(appID) if err != nil { return err @@ -264,7 +266,7 @@ func (self *SbiHttpClient) ReportDeploymentStatus(ctx context.Context, deviceID, }, } - resp, err := self.client.PostApiV1ClientsClientIdDeploymentsDeploymentIdStatus(ctx, deviceID, appUUID.String(), deploymentStatus) + resp, err := sbiClient.client.PostApiV1ClientsClientIdDeploymentsDeploymentIdStatus(ctx, deviceID, appUUID.String(), deploymentStatus) if err != nil { return err } @@ -274,9 +276,9 @@ func (self *SbiHttpClient) ReportDeploymentStatus(ctx context.Context, deviceID, } // FetchDeploymentYAML with caching support and enhanced logging -func (self *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClientId, deploymentId, digest string, overrideOptions ...HTTPApiClientRequestEditorOptions) (yamlContent []byte, err error) { +func (sbiClient *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClientId, deploymentId, digest string, overrideOptions ...HTTPApiClientRequestEditorOptions) (yamlContent []byte, err error) { // Check if we have this deployment cached - cachedDigest, cacheErr := self.deploymentCache.GetLastDeploymentDigest(deploymentId) + cachedDigest, cacheErr := sbiClient.deploymentCache.GetLastDeploymentDigest(deploymentId) params := &sbi.GetApiV1ClientsClientIdDeploymentsDeploymentIdDigestParams{} @@ -288,7 +290,7 @@ func (self *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClient deploymentId[:8], etag) } - resp, err := self.client.GetApiV1ClientsClientIdDeploymentsDeploymentIdDigest( + resp, err := sbiClient.client.GetApiV1ClientsClientIdDeploymentsDeploymentIdDigest( ctx, deviceClientId, deploymentId, @@ -306,7 +308,7 @@ func (self *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClient fmt.Printf("INFO: [Cache HIT] Deployment %s not modified (304) - using cached version\n", deploymentId[:8]) - cachedData, err := self.deploymentCache.GetDeployment(deploymentId, digest) + cachedData, err := sbiClient.deploymentCache.GetDeployment(deploymentId, digest) if err != nil { return nil, fmt.Errorf("304 received but cache read failed: %w", err) } @@ -336,7 +338,7 @@ func (self *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClient } // Store in cache (digest verification happens inside cache.Store) - if err := self.deploymentCache.StoreDeployment(deploymentId, digest, yamlContent); err != nil { + if err := sbiClient.deploymentCache.StoreDeployment(deploymentId, digest, yamlContent); err != nil { fmt.Printf("WARNING: [Cache] Failed to cache deployment %s: %v\n", deploymentId[:8], err) } else { fmt.Printf("INFO: [Cache] Stored deployment %s (digest: %s...)\n", @@ -347,9 +349,9 @@ func (self *SbiHttpClient) FetchDeploymentYAML(ctx context.Context, deviceClient } // DownloadBundle with caching support and enhanced logging -func (self *SbiHttpClient) DownloadBundle(ctx context.Context, deviceClientId, digest string, overrideOptions ...HTTPApiClientRequestEditorOptions) (bundleData []byte, err error) { +func (sbiClient *SbiHttpClient) DownloadBundle(ctx context.Context, deviceClientId, digest string, overrideOptions ...HTTPApiClientRequestEditorOptions) (bundleData []byte, err error) { // Check if we have this bundle cached - cachedDigest, cacheErr := self.bundleCache.GetLastBundleDigest(deviceClientId) + cachedDigest, cacheErr := sbiClient.bundleCache.GetLastBundleDigest(deviceClientId) params := &sbi.GetApiV1ClientsClientIdBundlesDigestParams{} @@ -361,7 +363,7 @@ func (self *SbiHttpClient) DownloadBundle(ctx context.Context, deviceClientId, d deviceClientId[:8], digest[:16]) } - resp, err := self.client.GetApiV1ClientsClientIdBundlesDigest( + resp, err := sbiClient.client.GetApiV1ClientsClientIdBundlesDigest( ctx, deviceClientId, digest, @@ -378,7 +380,7 @@ func (self *SbiHttpClient) DownloadBundle(ctx context.Context, deviceClientId, d fmt.Printf("INFO: [Cache HIT] Bundle not modified (304) - using cached version (device: %s)\n", deviceClientId[:8]) - cachedData, err := self.bundleCache.GetBundle(deviceClientId, digest) + cachedData, err := sbiClient.bundleCache.GetBundle(deviceClientId, digest) if err != nil { return nil, fmt.Errorf("304 received but cache read failed: %w", err) } @@ -410,7 +412,7 @@ func (self *SbiHttpClient) DownloadBundle(ctx context.Context, deviceClientId, d } // Store in cache (digest verification happens inside cache.Store) - if err := self.bundleCache.StoreBundle(deviceClientId, digest, bundleData); err != nil { + if err := sbiClient.bundleCache.StoreBundle(deviceClientId, digest, bundleData); err != nil { fmt.Printf("WARNING: [Cache] Failed to cache bundle for device %s: %v\n", deviceClientId[:8], err) } else { diff --git a/shared-lib/archive/archiver.go b/shared-lib/archive/archiver.go index 541e944c..28daa3f4 100644 --- a/shared-lib/archive/archiver.go +++ b/shared-lib/archive/archiver.go @@ -108,21 +108,21 @@ func (a *Archiver) CreateArchive() (archiveObject *os.File, digest string, size } if err != nil { - os.Remove(a.outputPath) + _ = os.Remove(a.outputPath) return nil, "", 0, "", err } // Calculate digest and size digest, size, err = a.calculateDigestAndSize() if err != nil { - os.Remove(a.outputPath) + _ = os.Remove(a.outputPath) return nil, "", 0, "", err } // Return archive file as archiveObject archiveFile, err := os.Open(a.outputPath) if err != nil { - os.Remove(a.outputPath) + _ = os.Remove(a.outputPath) return nil, "", 0, "", fmt.Errorf("failed to open created archive: %w", err) } @@ -130,20 +130,30 @@ func (a *Archiver) CreateArchive() (archiveObject *os.File, digest string, size } // createTarGzArchive creates a tar.gz archive -func (a *Archiver) createTarGzArchive(output *os.File) error { +func (a *Archiver) createTarGzArchive(output *os.File) (err error) { // Sort entries for deterministic ordering a.sortEntries() // Create gzip writer with deterministic settings gzipWriter := gzip.NewWriter(output) - gzipWriter.Header.Name = "" // Clear filename for reproducibility - gzipWriter.Header.Comment = "" // Clear comment for reproducibility - gzipWriter.Header.ModTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) // Fixed timestamp - defer gzipWriter.Close() + gzipWriter.Name = "" // Clear filename for reproducibility + gzipWriter.Comment = "" // Clear comment for reproducibility + gzipWriter.ModTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) // Fixed timestamp + defer func() { + closeErr := gzipWriter.Close() + if err == nil { + err = closeErr + } + }() // Create tar writer tarWriter := tar.NewWriter(gzipWriter) - defer tarWriter.Close() + defer func() { + closeErr := tarWriter.Close() + if err == nil { + err = closeErr + } + }() // Add all entries to archive (now sorted) for _, entry := range a.entries { @@ -165,7 +175,7 @@ func (a *Archiver) createTarGzArchive(output *os.File) error { // addFileToTar adds a file from filesystem to tar archive func (a *Archiver) addFileToTar(tarWriter *tar.Writer, nameInArchive, filePath string) error { - file, err := os.Open(filePath) + file, err := os.Open(filepath.Clean(filePath)) if err != nil { return err } @@ -233,7 +243,12 @@ func (a *Archiver) calculateDigestAndSize() (string, uint64, error) { if err != nil { return "", 0, err } + + // #nosec G115 -- check has been added to ensure no overflow size := uint64(fileInfo.Size()) + if fileInfo.Size() <= 0 { + size = 0 + } // Calculate SHA256 hash hasher := sha256.New() diff --git a/shared-lib/archive/archiver_test.go b/shared-lib/archive/archiver_test.go index fbd6a481..17f00cab 100644 --- a/shared-lib/archive/archiver_test.go +++ b/shared-lib/archive/archiver_test.go @@ -324,7 +324,7 @@ func TestArchiver_CreateArchive(t *testing.T) { // Helper function to calculate expected digest func calculateExpectedDigest(t *testing.T, filePath string) (string, uint64) { - file, err := os.Open(filePath) + file, err := os.Open(filepath.Clean(filePath)) if err != nil { t.Fatal(err) } @@ -341,12 +341,17 @@ func calculateExpectedDigest(t *testing.T, filePath string) (string, uint64) { } digest := hex.EncodeToString(hasher.Sum(nil)) - return fmt.Sprintf("sha256:%s", digest), uint64(fileInfo.Size()) + size := uint64(0) + if fileInfo.Size() > 0 { + // #nosec G115 -- value is checked above + size = uint64(fileInfo.Size()) + } + return fmt.Sprintf("sha256:%s", digest), size } // Helper function to verify archive content func verifyArchiveContent(t *testing.T, archivePath string, expectedEntries []ArchiveEntry) { - file, err := os.Open(archivePath) + file, err := os.Open(filepath.Clean(archivePath)) if err != nil { t.Fatal(err) } diff --git a/shared-lib/archive/extractor.go b/shared-lib/archive/extractor.go index 851dc46b..669a62e2 100644 --- a/shared-lib/archive/extractor.go +++ b/shared-lib/archive/extractor.go @@ -30,7 +30,9 @@ func (e *BundleExtractor) Extract() (map[string][]byte, error) { if err != nil { return nil, fmt.Errorf("failed to create gzip reader: %w", err) } - defer gzipReader.Close() + defer func() { + _ = gzipReader.Close() + }() // Create tar reader tarReader := tar.NewReader(gzipReader) diff --git a/shared-lib/cache/cache.go b/shared-lib/cache/cache.go index 52236bbf..330e014e 100644 --- a/shared-lib/cache/cache.go +++ b/shared-lib/cache/cache.go @@ -25,7 +25,7 @@ type Cache struct { // NewCache creates a new cache instance func NewCache(baseDir string) (*Cache, error) { - if err := os.MkdirAll(baseDir, 0755); err != nil { + if err := os.MkdirAll(baseDir, 0750); err != nil { return nil, fmt.Errorf("failed to create cache directory: %w", err) } @@ -48,12 +48,12 @@ func (c *Cache) Store(cacheType CacheType, key, digest string, data []byte) erro // Create cache path cachePath := filepath.Join(c.baseDir, string(cacheType), key, digest) - if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(cachePath), 0750); err != nil { return fmt.Errorf("failed to create cache directory: %w", err) } // Write data - if err := os.WriteFile(cachePath, data, 0644); err != nil { + if err := os.WriteFile(cachePath, data, 0600); err != nil { return fmt.Errorf("failed to write cache file: %w", err) } @@ -67,7 +67,7 @@ func (c *Cache) Get(cacheType CacheType, key, digest string) ([]byte, error) { defer c.mu.RUnlock() cachePath := filepath.Join(c.baseDir, string(cacheType), key, digest) - data, err := os.ReadFile(cachePath) + data, err := os.ReadFile(filepath.Clean(cachePath)) if err != nil { return nil, fmt.Errorf("cache miss: %w", err) } @@ -77,7 +77,7 @@ func (c *Cache) Get(cacheType CacheType, key, digest string) ([]byte, error) { actualDigest := fmt.Sprintf("sha256:%x", hash) if actualDigest != digest { // Cache corruption detected - remove corrupted file - os.Remove(cachePath) + _ = os.Remove(cachePath) return nil, fmt.Errorf("cache corruption detected: expected %s, got %s", digest, actualDigest) } @@ -90,7 +90,7 @@ func (c *Cache) GetLastDigest(cacheType CacheType, key string) (string, error) { defer c.mu.RUnlock() metaPath := filepath.Join(c.baseDir, string(cacheType), key, "metadata.json") - data, err := os.ReadFile(metaPath) + data, err := os.ReadFile(filepath.Clean(metaPath)) if err != nil { return "", fmt.Errorf("no cached metadata: %w", err) } @@ -159,7 +159,7 @@ func (c *Cache) updateMetadata(cacheType CacheType, key, digest string) error { return fmt.Errorf("failed to marshal metadata: %w", err) } - return os.WriteFile(metaPath, metaData, 0644) + return os.WriteFile(metaPath, metaData, 0600) } // GetCacheStats returns statistics about the cache diff --git a/shared-lib/certs/cert.go b/shared-lib/certs/cert.go deleted file mode 100644 index 2cc6fadb..00000000 --- a/shared-lib/certs/cert.go +++ /dev/null @@ -1,214 +0,0 @@ -package certs - -import ( - "bytes" - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "math/big" - "net/http" - "time" -) - -// --- Types matching OpenAPI spec --- -type PKIParameters struct { - CertificateAuthority string `json:"certificateAuthority"` - KeySize int `json:"keySize"` - Algorithm string `json:"algorithm"` - CSR string `json:"csr"` -} - -type OnboardingRequest struct { - DeviceInfo struct { - DeviceId string `json:"deviceId"` - Metadata map[string]interface{} `json:"metadata"` - } `json:"deviceInfo"` - Protocol struct { - Type string `json:"type"` - Version string `json:"version"` - Parameters PKIParameters `json:"parameters"` - } `json:"protocol"` - Metadata map[string]interface{} `json:"metadata"` -} - -type OnboardingResponse struct { - SessionId string `json:"sessionId"` - DeviceId string `json:"deviceId"` - Status string `json:"status"` - NextStep struct { - Action string `json:"action"` - Endpoint string `json:"endpoint"` - } `json:"nextStep"` - ExpiresAt string `json:"expiresAt"` -} - -type ChallengeRequest struct { - SessionId string `json:"sessionId"` - Response PKIResponse `json:"response"` -} - -type PKIResponse struct { - Certificate string `json:"certificate"` - PrivateKeyProof string `json:"privateKeyProof"` -} - -type ChallengeResponse struct { - SessionId string `json:"sessionId"` - Status string `json:"status"` -} - -// --- Client-side PKI onboarding --- -func generateKeyAndCSR(deviceId string) (*rsa.PrivateKey, string, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, "", err - } - template := x509.CertificateRequest{ - Subject: pkix.Name{ - CommonName: deviceId, - }, - } - csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, priv) - if err != nil { - return nil, "", err - } - csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes}) - return priv, base64.StdEncoding.EncodeToString(csrPEM), nil -} - -func clientOnboard(serverURL, deviceId string) (string, *rsa.PrivateKey, error) { - priv, csr, err := generateKeyAndCSR(deviceId) - if err != nil { - return "", nil, err - } - req := OnboardingRequest{ - DeviceInfo: struct { - DeviceId string `json:"deviceId"` - Metadata map[string]interface{} `json:"metadata"` - }{ - DeviceId: deviceId, - Metadata: map[string]interface{}{"annotations": map[string]string{"model": "x1"}}, - }, - Protocol: struct { - Type string `json:"type"` - Version string `json:"version"` - Parameters PKIParameters `json:"parameters"` - }{ - Type: "PKI", - Version: "1.0", - Parameters: PKIParameters{ - CertificateAuthority: "TestCA", - KeySize: 2048, - Algorithm: "RSA", - CSR: csr, - }, - }, - } - body, _ := json.Marshal(req) - resp, err := http.Post(serverURL+"/devices/onboard", "application/json", bytes.NewReader(body)) - if err != nil { - return "", nil, err - } - defer resp.Body.Close() - var onboardResp OnboardingResponse - json.NewDecoder(resp.Body).Decode(&onboardResp) - return onboardResp.SessionId, priv, nil -} - -func clientChallenge(serverURL, sessionId string, priv *rsa.PrivateKey, challenge []byte, certPEM string) error { - // Sign challenge - sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, challenge) - if err != nil { - return err - } - req := ChallengeRequest{ - SessionId: sessionId, - Response: PKIResponse{ - Certificate: certPEM, - PrivateKeyProof: base64.StdEncoding.EncodeToString(sig), - }, - } - body, _ := json.Marshal(req) - resp, err := http.Post(serverURL+"/devices/"+sessionId+"/onboard/challenge", "application/json", bytes.NewReader(body)) - if err != nil { - return err - } - defer resp.Body.Close() - var challengeResp ChallengeResponse - json.NewDecoder(resp.Body).Decode(&challengeResp) - if challengeResp.Status != "passed" { - return fmt.Errorf("challenge failed") - } - return nil -} - -// --- Server-side handlers --- -func handleOnboard(w http.ResponseWriter, r *http.Request) { - var req OnboardingRequest - json.NewDecoder(r.Body).Decode(&req) - csrBytes, _ := base64.StdEncoding.DecodeString(req.Protocol.Parameters.CSR) - block, _ := pem.Decode(csrBytes) - csr, _ := x509.ParseCertificateRequest(block.Bytes) - // Issue certificate (self-signed for demo) - template := x509.Certificate{ - SerialNumber: bigInt(time.Now().UnixNano()), - Subject: csr.Subject, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - BasicConstraintsValid: true, - } - certBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, csr.PublicKey, nil) - pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) - resp := OnboardingResponse{ - SessionId: "sess-123", - DeviceId: req.DeviceInfo.DeviceId, - Status: "challenge_required", - NextStep: struct { - Action string `json:"action"` - Endpoint string `json:"endpoint"` - }{ - Action: "authenticate", - Endpoint: "/devices/sess-123/onboard/challenge", - }, - ExpiresAt: time.Now().Add(10 * time.Minute).Format(time.RFC3339), - } - json.NewEncoder(w).Encode(resp) -} - -func handleChallenge(w http.ResponseWriter, r *http.Request) { - var req ChallengeRequest - json.NewDecoder(r.Body).Decode(&req) - certBytes, _ := base64.StdEncoding.DecodeString(req.Response.Certificate) - block, _ := pem.Decode(certBytes) - cert, _ := x509.ParseCertificate(block.Bytes) - challenge := []byte("server-challenge") // Should be random per session - sig, _ := base64.StdEncoding.DecodeString(req.Response.PrivateKeyProof) - err := rsa.VerifyPKCS1v15(cert.PublicKey.(*rsa.PublicKey), crypto.SHA256, challenge, sig) - status := "passed" - if err != nil { - status = "failed" - } - resp := ChallengeResponse{ - SessionId: req.SessionId, - Status: status, - } - json.NewEncoder(w).Encode(resp) -} - -func bigInt(n int64) *big.Int { - return big.NewInt(n) -} - -func main() { - http.HandleFunc("/devices/onboard", handleOnboard) - http.HandleFunc("/devices/sess-123/onboard/challenge", handleChallenge) - fmt.Println("Server running on :8080") - http.ListenAndServe(":8080", nil) -} diff --git a/shared-lib/certs/pki/README.md b/shared-lib/certs/pki/README.md deleted file mode 100644 index b2f7e50d..00000000 --- a/shared-lib/certs/pki/README.md +++ /dev/null @@ -1,213 +0,0 @@ -TBD: Add more documentation here. - -```mermaid -sequenceDiagram - participant Device as 🖥️ Device - participant Client as 📱 PKI Client - participant CA as 🏛️ Certificate Authority - participant Server as 🏢 Onboarding Server - participant DB as 🗄️ Device Registry - - Note over Device, DB: PKI Device Registration Flow - - Device->>Client: Generate key pair
(RSA/ECDSA 2048/4096-bit) - Client->>Client: Create CSR
(Certificate Signing Request) - Client->>CA: Submit CSR
(Device ID in CN/SAN) - - CA->>CA: Validate device identity
& authorization - CA->>CA: Sign certificate
(X.509 with device metadata) - CA->>Client: Return signed certificate
(PEM format) - - Client->>Client: Store certificate
& private key securely - Client->>Server: Initiate onboarding
(Certificate + metadata) - - Server->>Server: Validate certificate chain
against trusted CAs - Server->>Server: Extract device ID
from certificate - Server->>Server: Generate challenge
(random nonce) - - Server->>Client: Send challenge
(cryptographic nonce) - Client->>Client: Sign challenge
with private key - Client->>Server: Return signature
(proof of key possession) - - Server->>Server: Verify signature
using certificate public key - Server->>DB: Register device
(ID, certificate, metadata) - Server->>Client: Onboarding complete
(device credentials) - Client->>Device: Device ready for use - - Note over Device, DB: PKI Device Authentication Flow - - Device->>Server: Request service access
(present certificate) - Server->>Server: Validate certificate
(chain, expiry, revocation) - Server->>Server: Generate auth challenge - Server->>Device: Send challenge - - Device->>Device: Sign challenge
with private key - Device->>Server: Return signature - Server->>Server: Verify signature
& authorize access - Server->>Device: Grant access
(service tokens/session) -``` - ---- - -```mermaid -graph TB - subgraph "PKI Infrastructure" - subgraph "Device Side" - Device[🖥️ IoT Device/Endpoint] - HSM[🔐 Hardware Security Module
TPM/Secure Element] - Client[📱 PKI Client
Certificate Manager] - end - - subgraph "Certificate Authority" - RootCA[🏛️ Root CA
Offline/Air-gapped] - IntermediateCA[🏢 Intermediate CA
Device Issuing CA] - OCSP[📋 OCSP Responder
Revocation Status] - CRL[📜 Certificate Revocation List] - end - - subgraph "Onboarding Infrastructure" - OnboardServer[🏢 Onboarding Server
Registration Authority] - DeviceDB[(🗄️ Device Registry
Certificates & Metadata)] - PolicyEngine[⚙️ Policy Engine
Authorization Rules] - Monitor[📊 Monitoring
Device Lifecycle] - end - end - - Device --> HSM - HSM --> Client - Client --> IntermediateCA - Client --> OnboardServer - - RootCA --> IntermediateCA - IntermediateCA --> OCSP - IntermediateCA --> CRL - - OnboardServer --> DeviceDB - OnboardServer --> PolicyEngine - OnboardServer --> Monitor - - OnboardServer -.-> IntermediateCA - OnboardServer -.-> OCSP -``` - ---- - -```mermaid -graph TD - subgraph "PKI Trust Hierarchy" - RootCA[🏛️ Root CA
Self-Signed
Offline Storage] - - subgraph "Intermediate CAs" - DeviceCA[🏢 Device CA
Issues Device Certs] - UserCA[👤 User CA
Issues User Certs] - ServerCA[🖥️ Server CA
Issues Server Certs] - end - - subgraph "End Entity Certificates" - DeviceCert[🖥️ Device Certificate
Device Identity] - UserCert[👤 User Certificate
User Identity] - ServerCert[🖥️ Server Certificate
Service Identity] - end - end - - RootCA --> DeviceCA - RootCA --> UserCA - RootCA --> ServerCA - - DeviceCA --> DeviceCert - UserCA --> UserCert - ServerCA --> ServerCert - - subgraph "Security Controls" - HSM1[🔐 Hardware Security
Private Key Protection] - Revocation[🚫 Certificate Revocation
OCSP/CRL] - Validation[✅ Chain Validation
Trust Path Verification] - Expiry[⏰ Certificate Lifecycle
Renewal & Rotation] - end - - DeviceCert -.-> HSM1 - DeviceCert -.-> Revocation - DeviceCert -.-> Validation - DeviceCert -.-> Expiry -``` - ---- - -```mermaid -graph LR - subgraph "Device Lifecycle States" - A[🏭 Manufacturing
Key Generation] - B[📋 Pre-Registration
CSR Creation] - C[🔐 Certificate Issuance
CA Signing] - D[📱 Device Onboarding
Challenge-Response] - E[✅ Active/Operational
Service Access] - F[🔄 Certificate Renewal
Before Expiry] - G[🚫 Revocation
Compromise/Decommission] - H[💀 End of Life
Key Destruction] - end - - A --> B - B --> C - C --> D - D --> E - E --> F - F --> E - E --> G - G --> H - F --> G - - subgraph "Security Operations" - I[🔍 Monitoring
Certificate Status] - J[📊 Audit Logging
All Operations] - K[🛡️ Threat Detection
Anomaly Analysis] - L[🔧 Incident Response
Compromise Handling] - end - - E -.-> I - E -.-> J - E -.-> K - G -.-> L -``` - ---- - -```mermaid -graph TB - subgraph "X.509 Certificate Structure" - subgraph "Certificate Fields" - Version[📋 Version: v3] - Serial[🔢 Serial Number
Unique Identifier] - Signature[✍️ Signature Algorithm
RSA-SHA256/ECDSA-SHA256] - Issuer[🏛️ Issuer DN
CA Distinguished Name] - Validity[⏰ Validity Period
Not Before/Not After] - Subject[🖥️ Subject DN
Device Distinguished Name] - PublicKey[🔑 Public Key Info
Algorithm + Key] - Extensions[📎 X.509v3 Extensions
Key Usage, SAN, etc.] - end - - subgraph "Device-Specific Extensions" - DeviceID[🆔 Device ID
Subject CN/SAN] - KeyUsage[🔐 Key Usage
Digital Signature] - ExtKeyUsage[🎯 Extended Key Usage
Client Authentication] - Policies[📜 Certificate Policies
Device Class/Type] - end - end - - Subject --> DeviceID - Extensions --> KeyUsage - Extensions --> ExtKeyUsage - Extensions --> Policies - - subgraph "Validation Process" - ChainVal[🔗 Chain Validation
Root → Intermediate → Device] - SigVal[✅ Signature Validation
Cryptographic Verification] - TimeVal[⏰ Time Validation
Current Time in Validity] - RevVal[🚫 Revocation Check
OCSP/CRL Status] - PolicyVal[📋 Policy Validation
Usage Constraints] - end - - PublicKey --> SigVal - Validity --> TimeVal - Serial --> RevVal - Policies --> PolicyVal -``` diff --git a/shared-lib/certs/pki/auth.go b/shared-lib/certs/pki/auth.go deleted file mode 100644 index c2c8d5ba..00000000 --- a/shared-lib/certs/pki/auth.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package pki provides Public Key Infrastructure (PKI) authentication capabilities -// for device onboarding and identity verification. It implements certificate-based -// authentication using digital signatures and challenge-response protocols. -package pki - -import ( - "fmt" -) - -// PKIAuthenticator orchestrates PKI-based authentication workflows. -// It coordinates certificate management, challenge generation, and signature -// verification to provide secure device authentication using X.509 certificates. -// -// The authenticator follows a challenge-response pattern where: -// 1. Device presents its certificate -// 2. Server generates a cryptographic challenge -// 3. Device signs the challenge with its private key -// 4. Server verifies the signature using the device's public key -type PKIAuthenticator struct { - certManager *CertificateManager // Handles certificate parsing, validation, and chain verification - challengeGen *ChallengeGenerator // Creates and validates cryptographic challenges - signatureVerifier *SignatureVerifier // Verifies digital signatures against public keys -} - -// NewPKIAuthenticator returns a new PKI authenticator with the given dependencies. -func NewPKIAuthenticator( - certManager *CertificateManager, - challengeGen *ChallengeGenerator, - signatureVerifier *SignatureVerifier, -) *PKIAuthenticator { - return &PKIAuthenticator{ - certManager: certManager, - challengeGen: challengeGen, - signatureVerifier: signatureVerifier, - } -} - -// CreateDeviceIdentity parses a PEM certificate and returns the device identity. -// The device ID is extracted from the certificate's Subject or Subject Alternative Name. -func (auth *PKIAuthenticator) CreateDeviceIdentity(certPEM []byte) (*DeviceIdentity, error) { - cert, err := auth.certManager.ParseDeviceCertificate(certPEM) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate: %w", err) - } - - deviceID, err := auth.certManager.ExtractDeviceID(cert) - if err != nil { - return nil, fmt.Errorf("failed to extract device ID: %w", err) - } - - return &DeviceIdentity{ - DeviceID: deviceID, - Certificate: cert, - PublicKey: cert.PublicKey, // Extract public key for signature verification - }, nil -} - -// ValidateDeviceIdentity validates the device's certificate chain and expiry. -func (auth *PKIAuthenticator) ValidateDeviceIdentity(identity *DeviceIdentity) error { - // Verify the certificate chain to ensure it's signed by a trusted CA - if err := auth.certManager.VerifyCertificateChain(identity.Certificate); err != nil { - return fmt.Errorf("certificate chain validation failed: %w", err) - } - - // Check certificate hasn't expired (and optionally not yet valid) - if err := auth.certManager.ValidateCertificateExpiry(identity.Certificate); err != nil { - return fmt.Errorf("certificate expiry validation failed: %w", err) - } - - return nil -} - -// GenerateAuthenticationChallenge creates a time-bound cryptographic challenge for the device. -func (auth *PKIAuthenticator) GenerateAuthenticationChallenge(deviceID string) (*Challenge, error) { - return auth.challengeGen.GenerateChallenge(deviceID) -} - -// VerifyAuthenticationResponse verifies the device's signature against the challenge. -// It returns an AuthenticationResult with success status and error details. -func (auth *PKIAuthenticator) VerifyAuthenticationResponse( - identity *DeviceIdentity, - challenge *Challenge, - signature []byte, -) *AuthenticationResult { - result := &AuthenticationResult{ - DeviceID: identity.DeviceID, - Certificate: identity.Certificate, - } - - // Validate challenge hasn't expired and is still usable - if !auth.challengeGen.IsValidChallenge(challenge) { - result.ErrorMessage = "challenge has expired" - return result - } - - // Ensure challenge was issued for this specific device (prevents challenge reuse) - if challenge.DeviceID != identity.DeviceID { - result.ErrorMessage = "challenge device ID mismatch" - return result - } - - // Prepare signature verification input with challenge data and device's public key - verificationInput := SignatureVerificationInput{ - Data: challenge.Value, // The original challenge data that was signed - Signature: signature, // The signature provided by the device - PublicKey: identity.PublicKey, // Public key from the device's certificate - } - - // Verify the signature using the appropriate algorithm (RSA, ECDSA, etc.) - if err := auth.signatureVerifier.VerifySignature(verificationInput); err != nil { - result.ErrorMessage = fmt.Sprintf("signature verification failed: %v", err) - return result - } - - result.Success = true - return result -} - -// PerformFullAuthentication performs complete PKI authentication including -// certificate validation and challenge-response verification. -func (auth *PKIAuthenticator) PerformFullAuthentication( - certPEM []byte, - challenge *Challenge, - signature []byte, -) *AuthenticationResult { - // Step 1: Create device identity from certificate - identity, err := auth.CreateDeviceIdentity(certPEM) - if err != nil { - return &AuthenticationResult{ - ErrorMessage: fmt.Sprintf("failed to create device identity: %v", err), - } - } - - // Step 2: Validate the device's certificate - if err := auth.ValidateDeviceIdentity(identity); err != nil { - return &AuthenticationResult{ - DeviceID: identity.DeviceID, - Certificate: identity.Certificate, - ErrorMessage: fmt.Sprintf("certificate validation failed: %v", err), - } - } - - // Step 3: Verify the authentication response - return auth.VerifyAuthenticationResponse(identity, challenge, signature) -} diff --git a/shared-lib/certs/pki/cert.go b/shared-lib/certs/pki/cert.go deleted file mode 100644 index 7bd9992c..00000000 --- a/shared-lib/certs/pki/cert.go +++ /dev/null @@ -1,98 +0,0 @@ -// Package pki provides PKI-based device authentication using X.509 certificates. -package pki - -import ( - "crypto/x509" - "encoding/pem" - "fmt" - "time" -) - -// CertificateManager handles X.509 certificate operations including parsing, -// validation, and chain verification against trusted CAs. -type CertificateManager struct { - trustedCAs []*x509.Certificate -} - -// NewCertificateManager creates a new certificate manager with the given CA certificates. -// All CA certificates must be valid PEM-encoded X.509 certificates. -func NewCertificateManager(caPEMs [][]byte) (*CertificateManager, error) { - var cas []*x509.Certificate - - for _, caPEM := range caPEMs { - ca, err := parseCertificateFromPEM(caPEM) - if err != nil { - return nil, fmt.Errorf("failed to parse CA certificate: %w", err) - } - cas = append(cas, ca) - } - - return &CertificateManager{trustedCAs: cas}, nil -} - -// ParseDeviceCertificate parses a PEM-encoded X.509 certificate. -func (cm *CertificateManager) ParseDeviceCertificate(certPEM []byte) (*x509.Certificate, error) { - return parseCertificateFromPEM(certPEM) -} - -// VerifyCertificateChain verifies the certificate against the trusted CA certificates. -// The certificate must be valid for client authentication. -func (cm *CertificateManager) VerifyCertificateChain(cert *x509.Certificate) error { - roots := x509.NewCertPool() - for _, ca := range cm.trustedCAs { - roots.AddCert(ca) - } - - opts := x509.VerifyOptions{ - Roots: roots, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - - _, err := cert.Verify(opts) - return err -} - -// ValidateCertificateExpiry checks if the certificate is within its validity period. -func (cm *CertificateManager) ValidateCertificateExpiry(cert *x509.Certificate) error { - now := time.Now() - if now.Before(cert.NotBefore) { - return fmt.Errorf("certificate not yet valid") - } - if now.After(cert.NotAfter) { - return fmt.Errorf("certificate has expired") - } - return nil -} - -// ExtractDeviceID extracts the device identifier from the certificate's Common Name -// or DNS Subject Alternative Names. Returns an error if no device ID is found. -func (cm *CertificateManager) ExtractDeviceID(cert *x509.Certificate) (string, error) { - // Try Common Name first - if cert.Subject.CommonName != "" { - return cert.Subject.CommonName, nil - } - - // Try Subject Alternative Names - for _, name := range cert.DNSNames { - if name != "" { - return name, nil - } - } - - return "", fmt.Errorf("no device ID found in certificate") -} - -// parseCertificateFromPEM parses a PEM-encoded certificate block into an X.509 certificate. -func parseCertificateFromPEM(certPEM []byte) (*x509.Certificate, error) { - block, _ := pem.Decode(certPEM) - if block == nil { - return nil, fmt.Errorf("failed to decode PEM certificate") - } - - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate: %w", err) - } - - return cert, nil -} diff --git a/shared-lib/certs/pki/challenge.go b/shared-lib/certs/pki/challenge.go deleted file mode 100644 index 6974cff5..00000000 --- a/shared-lib/certs/pki/challenge.go +++ /dev/null @@ -1,53 +0,0 @@ -// Package pki provides PKI-based device authentication using X.509 certificates. -package pki - -import ( - "crypto/rand" - "fmt" - "time" -) - -// ChallengeGenerator generates time-bound cryptographic challenges for device authentication. -// It creates random byte sequences that devices must sign to prove private key possession. -type ChallengeGenerator struct { - challengeSize int // Size of challenge in bytes - defaultTTL time.Duration // Time-to-live for generated challenges -} - -// NewChallengeGenerator creates a new challenge generator with the specified parameters. -// If challengeSize is <= 0, defaults to 32 bytes. If defaultTTL is <= 0, defaults to 5 minutes. -func NewChallengeGenerator(challengeSize int, defaultTTL time.Duration) *ChallengeGenerator { - if challengeSize <= 0 { - challengeSize = 32 // Default 32 bytes - } - if defaultTTL <= 0 { - defaultTTL = 5 * time.Minute // Default 5 minutes - } - - return &ChallengeGenerator{ - challengeSize: challengeSize, - defaultTTL: defaultTTL, - } -} - -// GenerateChallenge creates a new cryptographic challenge for the specified device. -// The challenge contains random bytes that expire after the configured TTL. -func (cg *ChallengeGenerator) GenerateChallenge(deviceID string) (*Challenge, error) { - challengeBytes := make([]byte, cg.challengeSize) - if _, err := rand.Read(challengeBytes); err != nil { - return nil, fmt.Errorf("failed to generate random challenge: %w", err) - } - - now := time.Now() - return &Challenge{ - Value: challengeBytes, - DeviceID: deviceID, - CreatedAt: now, - ExpiresAt: now.Add(cg.defaultTTL), - }, nil -} - -// IsValidChallenge returns true if the challenge has not expired. -func (cg *ChallengeGenerator) IsValidChallenge(challenge *Challenge) bool { - return time.Now().Before(challenge.ExpiresAt) -} diff --git a/shared-lib/certs/pki/client.go b/shared-lib/certs/pki/client.go deleted file mode 100644 index 7a34e33d..00000000 --- a/shared-lib/certs/pki/client.go +++ /dev/null @@ -1,85 +0,0 @@ -// Package pki provides PKI-based device authentication using X.509 certificates. -package pki - -import ( - "crypto" - "crypto/ecdsa" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/pem" - "fmt" -) - -// PKIClient handles client-side PKI operations including challenge signing -// and certificate management for device authentication. -type PKIClient struct { - deviceID string - privateKey interface{} // RSA or ECDSA private key - certPEM []byte -} - -// NewPKIClient creates a new PKI client with the given device credentials. -// The private key must be in PEM format and can be RSA, ECDSA, PKCS#1, or PKCS#8. -func NewPKIClient(deviceID string, privateKeyPEM, certPEM []byte) (*PKIClient, error) { - privateKey, err := parsePrivateKeyFromPEM(privateKeyPEM) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - - return &PKIClient{ - deviceID: deviceID, - privateKey: privateKey, - certPEM: certPEM, - }, nil -} - -// SignChallenge signs the challenge data using the device's private key. -// It supports both RSA (PKCS#1 v1.5) and ECDSA (ASN.1) signatures with SHA-256. -func (client *PKIClient) SignChallenge(challengeData []byte) ([]byte, error) { - hash := sha256.Sum256(challengeData) - - switch privKey := client.privateKey.(type) { - case *rsa.PrivateKey: - return rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, hash[:]) - case *ecdsa.PrivateKey: - return ecdsa.SignASN1(rand.Reader, privKey, hash[:]) - default: - return nil, fmt.Errorf("unsupported private key type: %T", client.privateKey) - } -} - -// GetDeviceID returns the device identifier. -func (client *PKIClient) GetDeviceID() string { - return client.deviceID -} - -// GetCertificatePEM returns the device certificate in PEM format. -func (client *PKIClient) GetCertificatePEM() []byte { - return client.certPEM -} - -// parsePrivateKeyFromPEM parses a PEM-encoded private key in various formats. -// It attempts PKCS#1, PKCS#8, and EC private key formats. -func parsePrivateKeyFromPEM(privateKeyPEM []byte) (interface{}, error) { - block, _ := pem.Decode(privateKeyPEM) - if block == nil { - return nil, fmt.Errorf("failed to decode PEM private key") - } - - // Try different private key formats - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - return key, nil - } - - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - - return nil, fmt.Errorf("unsupported private key format") -} diff --git a/shared-lib/certs/pki/example.go b/shared-lib/certs/pki/example.go deleted file mode 100644 index 9a8cbaab..00000000 --- a/shared-lib/certs/pki/example.go +++ /dev/null @@ -1,272 +0,0 @@ -// certs/pki/example.go -package pki - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" -) - -type AuthService struct { - authenticator *PKIAuthenticator - challengeStore map[string]*Challenge // Use Redis in production -} - -func NewAuthService(caPEMs [][]byte) (*AuthService, error) { - certManager, err := NewCertificateManager(caPEMs) - if err != nil { - return nil, err - } - - challengeGen := NewChallengeGenerator(32, 5*time.Minute) - signatureVerifier := NewSignatureVerifier() - - authenticator := NewPKIAuthenticator(certManager, challengeGen, signatureVerifier) - - return &AuthService{ - authenticator: authenticator, - challengeStore: make(map[string]*Challenge), - }, nil -} - -// Step 1: Generate challenge for device -func (s *AuthService) CreateChallenge(deviceID string) (string, error) { - challenge, err := s.authenticator.GenerateAuthenticationChallenge(deviceID) - if err != nil { - return "", fmt.Errorf("failed to generate challenge: %w", err) - } - - // Store challenge (use Redis with TTL in production) - s.challengeStore[deviceID] = challenge - - // Return base64 encoded challenge - return base64.StdEncoding.EncodeToString(challenge.Value), nil -} - -// Step 2: Verify device authentication -func (s *AuthService) VerifyAuthentication(deviceID string, certPEM []byte, signatureB64 string) (*AuthenticationResult, error) { - // Get stored challenge - challenge, exists := s.challengeStore[deviceID] - if !exists { - return &AuthenticationResult{ - DeviceID: deviceID, - ErrorMessage: "no challenge found for device", - }, nil - } - - // Decode signature - signature, err := base64.StdEncoding.DecodeString(signatureB64) - if err != nil { - return &AuthenticationResult{ - DeviceID: deviceID, - ErrorMessage: "invalid signature encoding", - }, nil - } - - // Perform authentication - result := s.authenticator.PerformFullAuthentication(certPEM, challenge, signature) - - // Clean up challenge on success or failure - delete(s.challengeStore, deviceID) - - return result, nil -} - -// Example server main function -func serverMain() { - // Load CA certificates - caPEM := []byte(`-----BEGIN CERTIFICATE----- -MIICxjCCAa4CAQAwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIVGVzdCBDQSAwHhcN... ------END CERTIFICATE-----`) - - authService, err := NewAuthService([][]byte{caPEM}) - if err != nil { - log.Fatal("Failed to create auth service:", err) - } - - // Simulate authentication flow - deviceID := "device-12345" - - // Step 1: Generate challenge - challengeB64, err := authService.CreateChallenge(deviceID) - if err != nil { - log.Fatal("Failed to create challenge:", err) - } - - fmt.Printf("Generated challenge for device %s: %s\n", deviceID, challengeB64) - - // Step 2: Verify authentication (would come from client) - // This would typically be called from your HTTP handler - deviceCertPEM := []byte(`-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----`) - signatureFromClient := "base64-encoded-signature-from-client" - - result, err := authService.VerifyAuthentication(deviceID, deviceCertPEM, signatureFromClient) - if err != nil { - log.Fatal("Authentication error:", err) - } - - if result.Success { - fmt.Printf("Device %s authenticated successfully!\n", result.DeviceID) - // Generate JWT token, create session, etc. - } else { - fmt.Printf("Authentication failed: %s\n", result.ErrorMessage) - } -} - -// ---------------------------------------------------------------------------- -// ---------------------------------------------------------------------------- -// ---------------------------------------------------------------------------- -// certs/pki/example.go - -type DeviceClient struct { - pkiClient *PKIClient - serverURL string - httpClient *http.Client -} - -func NewDeviceClient(deviceID string, privateKeyPEM, certPEM []byte, serverURL string) (*DeviceClient, error) { - pkiClient, err := NewPKIClient(deviceID, privateKeyPEM, certPEM) - if err != nil { - return nil, fmt.Errorf("failed to create PKI client: %w", err) - } - - return &DeviceClient{ - pkiClient: pkiClient, - serverURL: serverURL, - httpClient: &http.Client{}, - }, nil -} - -// Step 1: Request challenge from server -func (c *DeviceClient) RequestChallenge() (string, error) { - url := fmt.Sprintf("%s/auth/challenge?device_id=%s", c.serverURL, c.pkiClient.GetDeviceID()) - - resp, err := c.httpClient.Get(url) - if err != nil { - return "", fmt.Errorf("failed to request challenge: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("server returned status: %d", resp.StatusCode) - } - - var challengeResp struct { - Challenge string `json:"challenge"` - } - - if err := json.NewDecoder(resp.Body).Decode(&challengeResp); err != nil { - return "", fmt.Errorf("failed to decode challenge response: %w", err) - } - - return challengeResp.Challenge, nil -} - -// Step 2: Authenticate with server -func (c *DeviceClient) Authenticate() (*AuthResult, error) { - // Get challenge from server - challengeB64, err := c.RequestChallenge() - if err != nil { - return nil, fmt.Errorf("failed to get challenge: %w", err) - } - - // Decode challenge - challengeData, err := base64.StdEncoding.DecodeString(challengeB64) - if err != nil { - return nil, fmt.Errorf("failed to decode challenge: %w", err) - } - - // Sign challenge - signature, err := c.pkiClient.SignChallenge(challengeData) - if err != nil { - return nil, fmt.Errorf("failed to sign challenge: %w", err) - } - - // Prepare authentication request - authReq := AuthRequest{ - DeviceID: c.pkiClient.GetDeviceID(), - Challenge: challengeB64, - Signature: base64.StdEncoding.EncodeToString(signature), - Certificate: base64.StdEncoding.EncodeToString(c.pkiClient.GetCertificatePEM()), - } - - // Send authentication request - return c.sendAuthRequest(authReq) -} - -func (c *DeviceClient) sendAuthRequest(authReq AuthRequest) (*AuthResult, error) { - jsonData, err := json.Marshal(authReq) - if err != nil { - return nil, fmt.Errorf("failed to marshal auth request: %w", err) - } - - url := fmt.Sprintf("%s/auth/verify", c.serverURL) - resp, err := c.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to send auth request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var authResult AuthResult - if err := json.Unmarshal(body, &authResult); err != nil { - return nil, fmt.Errorf("failed to decode auth response: %w", err) - } - - return &authResult, nil -} - -// Data structures for API communication -type AuthRequest struct { - DeviceID string `json:"device_id"` - Challenge string `json:"challenge"` - Signature string `json:"signature"` - Certificate string `json:"certificate"` -} - -type AuthResult struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - Error string `json:"error,omitempty"` -} - -// Example client main function -func clientMain() { - // Load device credentials - deviceID := "device-12345" - privateKeyPEM := []byte(`-----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA... ------END RSA PRIVATE KEY-----`) - - certPEM := []byte(`-----BEGIN CERTIFICATE----- -MIICxjCCAa4CAQAwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIVGVzdCBDQSAwHhcN... ------END CERTIFICATE-----`) - - // Create device client - client, err := NewDeviceClient(deviceID, privateKeyPEM, certPEM, "https://api.example.com") - if err != nil { - log.Fatal("Failed to create device client:", err) - } - - // Authenticate with server - result, err := client.Authenticate() - if err != nil { - log.Fatal("Authentication failed:", err) - } - - if result.Success { - fmt.Printf("Authentication successful! Token: %s\n", result.Token) - // Use token for subsequent API calls - } else { - fmt.Printf("Authentication failed: %s\n", result.Error) - } -} diff --git a/shared-lib/certs/pki/sign.go b/shared-lib/certs/pki/sign.go deleted file mode 100644 index 7a5c0ca5..00000000 --- a/shared-lib/certs/pki/sign.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package pki provides PKI-based device authentication using X.509 certificates. -package pki - -import ( - "crypto" - "crypto/ecdsa" - "crypto/rsa" - "crypto/sha256" - "fmt" -) - -// SignatureVerifier handles cryptographic signature verification using RSA and ECDSA algorithms. -type SignatureVerifier struct{} - -// NewSignatureVerifier creates a new signature verifier. -func NewSignatureVerifier() *SignatureVerifier { - return &SignatureVerifier{} -} - -// VerifySignature verifies a signature against data using the provided public key. -// It supports RSA (PKCS#1 v1.5) and ECDSA (ASN.1) signatures with SHA-256 hashing. -func (sv *SignatureVerifier) VerifySignature(input SignatureVerificationInput) error { - hash := sha256.Sum256(input.Data) - - switch pubKey := input.PublicKey.(type) { - case *rsa.PublicKey: - return rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], input.Signature) - case *ecdsa.PublicKey: - return sv.verifyECDSASignature(pubKey, hash[:], input.Signature) - default: - return fmt.Errorf("unsupported public key type: %T", input.PublicKey) - } -} - -// verifyECDSASignature verifies an ECDSA signature in ASN.1 DER format. -func (sv *SignatureVerifier) verifyECDSASignature(pubKey *ecdsa.PublicKey, hash, signature []byte) error { - if !ecdsa.VerifyASN1(pubKey, hash, signature) { - return fmt.Errorf("ECDSA signature verification failed") - } - return nil -} diff --git a/shared-lib/certs/pki/types.go b/shared-lib/certs/pki/types.go deleted file mode 100644 index d3f11eff..00000000 --- a/shared-lib/certs/pki/types.go +++ /dev/null @@ -1,41 +0,0 @@ -// Package pki provides PKI-based device authentication using X.509 certificates. -package pki - -import ( - "crypto/x509" - "time" -) - -// DeviceIdentity represents a device's PKI identity containing its certificate -// and extracted public key for authentication operations. -type DeviceIdentity struct { - DeviceID string // Unique device identifier extracted from certificate - Certificate *x509.Certificate // Device's X.509 certificate - PublicKey interface{} // Public key from certificate (RSA or ECDSA) -} - -// Challenge represents a time-bound cryptographic challenge used in -// challenge-response authentication protocols. -type Challenge struct { - Value []byte // Random bytes that must be signed by the device - DeviceID string // Device identifier this challenge was issued for - CreatedAt time.Time // When the challenge was generated - ExpiresAt time.Time // When the challenge expires -} - -// AuthenticationResult contains the outcome of PKI authentication verification -// including success status and error details. -type AuthenticationResult struct { - Success bool // Whether authentication succeeded - DeviceID string // Device identifier being authenticated - Certificate *x509.Certificate // Device certificate (if available) - ErrorMessage string // Error description if authentication failed -} - -// SignatureVerificationInput contains the data required to verify a digital signature -// against a public key. -type SignatureVerificationInput struct { - Data []byte // Original data that was signed - Signature []byte // Digital signature to verify - PublicKey interface{} // Public key for verification (RSA or ECDSA) -} diff --git a/shared-lib/crypto/digest.go b/shared-lib/crypto/digest.go index b3ee2ca3..740c8e8e 100644 --- a/shared-lib/crypto/digest.go +++ b/shared-lib/crypto/digest.go @@ -6,19 +6,20 @@ import ( "fmt" "io" "os" + "path/filepath" ) // GetDigestOfFile calculates the SHA256 digest of a file -func GetDigestOfFile(filepath string) (digest string, err error) { +func GetDigestOfFile(fPath string) (digest string, err error) { // Validate input - if filepath == "" { + if fPath == "" { return "", fmt.Errorf("filepath cannot be empty") } // Open the file - file, err := os.Open(filepath) + file, err := os.Open(filepath.Clean(fPath)) if err != nil { - return "", fmt.Errorf("failed to open file %s: %w", filepath, err) + return "", fmt.Errorf("failed to open file %s: %w", fPath, err) } defer file.Close() @@ -27,7 +28,7 @@ func GetDigestOfFile(filepath string) (digest string, err error) { // Copy file content to hasher if _, err := io.Copy(hasher, file); err != nil { - return "", fmt.Errorf("failed to read file %s: %w", filepath, err) + return "", fmt.Errorf("failed to read file %s: %w", fPath, err) } // Calculate digest @@ -57,16 +58,16 @@ func GetDigestOfContent(content []byte) (digest string, err error) { // Alternative implementation if you want to keep the original signature // GetDigestOfContentFromFile reads content from file and calculates digest -func GetDigestOfContentFromFile(filepath string) (digest string, err error) { +func GetDigestOfContentFromFile(fPath string) (digest string, err error) { // Validate input - if filepath == "" { + if fPath == "" { return "", fmt.Errorf("filepath cannot be empty") } // Read file content - content, err := os.ReadFile(filepath) + content, err := os.ReadFile(filepath.Clean(fPath)) if err != nil { - return "", fmt.Errorf("failed to read file %s: %w", filepath, err) + return "", fmt.Errorf("failed to read file %s: %w", fPath, err) } // Calculate digest of content diff --git a/shared-lib/crypto/signer.go b/shared-lib/crypto/signer.go index 0bee2ffc..a1c28f6e 100644 --- a/shared-lib/crypto/signer.go +++ b/shared-lib/crypto/signer.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "github.com/lestrrat-go/htmsig/component" @@ -34,9 +35,8 @@ type HTMPayloadSigner struct { signatureFormat string } -func NewSignerFromFile(filepath, signatureAlgo, hashAlgo, signatureFormat string) (HTTPSigner, error) { - keyPath := filepath - keyBytes, err := os.ReadFile(keyPath) +func NewSignerFromFile(keyPath, signatureAlgo, hashAlgo, signatureFormat string) (HTTPSigner, error) { + keyBytes, err := os.ReadFile(filepath.Clean(keyPath)) if err != nil { return nil, fmt.Errorf("failed to read request signer key from %s: %w", keyPath, err) } diff --git a/shared-lib/crypto/signer_test.go b/shared-lib/crypto/signer_test.go index bfdc7f7d..845c31f4 100644 --- a/shared-lib/crypto/signer_test.go +++ b/shared-lib/crypto/signer_test.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "net/http" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -50,8 +51,14 @@ func TestSignVerifyRoundTrip(t *testing.T) { // create request with body body := []byte("hello world") - req, err := http.NewRequest("POST", "https://example.com/api/v1/resource", bytes.NewReader(body)) + url := "https://somerandomurl.willnothit.com/api/v1/resource" + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // 2. Create the request with that context + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") err = signer.SignRequest(context.Background(), req) require.NoError(t, err) diff --git a/shared-lib/crypto/tls.go b/shared-lib/crypto/tls.go index 64cbfa57..bc9f42c3 100644 --- a/shared-lib/crypto/tls.go +++ b/shared-lib/crypto/tls.go @@ -5,12 +5,13 @@ import ( "crypto/x509" "fmt" "os" + "path/filepath" ) // LoadCustomCA loads a custom CA certificate and returns a TLS config func LoadCustomCA(caPath string) (*tls.Config, error) { // Read the CA certificate file - caCert, err := os.ReadFile(caPath) + caCert, err := os.ReadFile(filepath.Clean(caPath)) if err != nil { return nil, fmt.Errorf("failed to read CA certificate from %s: %w", caPath, err) } diff --git a/shared-lib/crypto/verifier.go b/shared-lib/crypto/verifier.go index 5d7ede8a..7328e969 100644 --- a/shared-lib/crypto/verifier.go +++ b/shared-lib/crypto/verifier.go @@ -99,10 +99,10 @@ func NewVerifier(publicKey string, isPubKeyBase64 bool) (*HTMPayloadVerifier, er return nil, fmt.Errorf("failed to parse public key (tried PKIX, PKCS1 and certificate): %v", parseErr) } -func (self *HTMPayloadVerifier) VerifyRequest(ctx context.Context, req *http.Request) error { - return self.verifier.VerifyRequest(ctx, req) +func (payloadVerifier *HTMPayloadVerifier) VerifyRequest(ctx context.Context, req *http.Request) error { + return payloadVerifier.verifier.VerifyRequest(ctx, req) } -func (self *HTMPayloadVerifier) VerifyResponse(ctx context.Context, resp *http.ResponseWriter) error { +func (payloadVerifier *HTMPayloadVerifier) VerifyResponse(ctx context.Context, resp *http.ResponseWriter) error { return fmt.Errorf("response verifier is not implemented") } diff --git a/shared-lib/file/http.go b/shared-lib/file/http.go index 41c77fc3..ab365f44 100644 --- a/shared-lib/file/http.go +++ b/shared-lib/file/http.go @@ -1,6 +1,7 @@ package file import ( + "context" "fmt" "io" "net/http" @@ -48,13 +49,11 @@ func DownloadFileUsingHttp(httpVerb, url string, auth *auth.AuthConfig, queryPar } } - // Create HTTP client with timeout - client := &http.Client{ - Timeout: options.Timeout, - } + ctx, cancel := context.WithTimeout(context.Background(), options.Timeout) + defer cancel() // Create HTTP request using the reusable methods - req, err := createHTTPRequest(httpVerb, url, auth, queryParams, body, options) + req, err := createHTTPRequest(ctx, httpVerb, url, auth, queryParams, body, options) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) } @@ -77,6 +76,7 @@ func DownloadFileUsingHttp(httpVerb, url string, auth *auth.AuthConfig, queryPar } // Execute the request + client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) @@ -100,7 +100,7 @@ func DownloadFileUsingHttp(httpVerb, url string, auth *auth.AuthConfig, queryPar if options.CreateDirs { dir := filepath.Dir(outputPath) if dir != "." && dir != "/" { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0750); err != nil { return nil, fmt.Errorf("failed to create directories: %w", err) } } @@ -123,7 +123,7 @@ func DownloadFileUsingHttp(httpVerb, url string, auth *auth.AuthConfig, queryPar } // createHTTPRequest creates an HTTP request using the reusable HTTP utility methods -func createHTTPRequest(httpVerb, url string, auth *auth.AuthConfig, queryParams map[string]interface{}, body interface{}, options *DownloadOptions) (*http.Request, error) { +func createHTTPRequest(ctx context.Context, httpVerb, url string, auth *auth.AuthConfig, queryParams map[string]interface{}, body interface{}, options *DownloadOptions) (*http.Request, error) { // Normalize HTTP verb httpVerb = strings.ToUpper(httpVerb) @@ -133,22 +133,22 @@ func createHTTPRequest(httpVerb, url string, auth *auth.AuthConfig, queryParams // Use the appropriate HTTP utility method based on verb switch httpVerb { case "GET": - req, err = httputils.NewGetRequest(url, auth, queryParams) + req, err = httputils.NewGetRequest(ctx, url, auth, queryParams) case "POST": contentType := getContentTypeFromHeaders(options.Headers) - req, err = httputils.NewPostRequest(url, auth, body, contentType) + req, err = httputils.NewPostRequest(ctx, url, auth, body, contentType) case "PUT": contentType := getContentTypeFromHeaders(options.Headers) - req, err = httputils.NewPutRequest(url, auth, body, contentType) + req, err = httputils.NewPutRequest(ctx, url, auth, body, contentType) case "PATCH": contentType := getContentTypeFromHeaders(options.Headers) - req, err = httputils.NewPatchRequest(url, auth, body, contentType) + req, err = httputils.NewPatchRequest(ctx, url, auth, body, contentType) case "DELETE": - req, err = httputils.NewDeleteRequest(url, auth, queryParams) + req, err = httputils.NewDeleteRequest(ctx, url, auth, queryParams) case "HEAD": - req, err = httputils.NewHeadRequest(url, auth, queryParams) + req, err = httputils.NewHeadRequest(ctx, url, auth, queryParams) case "OPTIONS": - req, err = httputils.NewOptionsRequest(url, auth) + req, err = httputils.NewOptionsRequest(ctx, url, auth) default: return nil, fmt.Errorf("unsupported HTTP verb: %s", httpVerb) } @@ -214,28 +214,6 @@ func validateResponse(resp *http.Response, resumeDownload bool) error { } } -// generateFilename generates an output path from URL and response headers -func generateFilename(url string, resp *http.Response) (string, error) { - // Try to get filename from Content-Disposition header - if cd := resp.Header.Get("Content-Disposition"); cd != "" { - if filename := extractFilenameFromContentDisposition(cd); filename != "" { - return filename, nil - } - } - - // Extract filename from URL - if filename := filepath.Base(url); filename != "" && filename != "." && filename != "/" { - // Remove query parameters - if idx := strings.Index(filename, "?"); idx != -1 { - filename = filename[:idx] - } - return filename, nil - } - - // Generate a default filename - return fmt.Sprintf("download_%d", time.Now().Unix()), nil -} - // downloadFile performs the actual file download func downloadFile(resp *http.Response, outputPath string, options *DownloadOptions) (*DownloadResult, error) { // Get content length @@ -257,10 +235,10 @@ func downloadFile(resp *http.Response, outputPath string, options *DownloadOptio if options.ResumeDownload && resp.StatusCode == http.StatusPartialContent { // Open file for appending - file, err = os.OpenFile(outputPath, os.O_WRONLY|os.O_APPEND, 0644) + file, err = os.OpenFile(filepath.Clean(outputPath), os.O_WRONLY|os.O_APPEND, 0600) } else { // Create new file or truncate existing - file, err = os.Create(outputPath) + file, err = os.Create(filepath.Clean(outputPath)) } if err != nil { diff --git a/shared-lib/file/http_test.go b/shared-lib/file/http_test.go index f4f0fe9b..a61c02b1 100644 --- a/shared-lib/file/http_test.go +++ b/shared-lib/file/http_test.go @@ -88,6 +88,7 @@ func TestDownloadFileUsingHttp_AuthenticationRequired(t *testing.T) { OutputPath: filepath.Join(tempDir, "auth-file.txt"), CreateDirs: true, OverwriteExist: true, + Timeout: 10 * time.Second, } result, err := DownloadFileUsingHttp("GET", server.URL+"/secure", auth, nil, nil, options) @@ -111,6 +112,7 @@ func TestDownloadFileUsingHttp_FileSizeLimitExceeded(t *testing.T) { MaxFileSize: 1024, // 1KB limit CreateDirs: true, OverwriteExist: true, + Timeout: 10 * time.Second, } _, err := DownloadFileUsingHttp("GET", server.URL+"/large-file", nil, nil, nil, options) @@ -129,6 +131,7 @@ func TestDownloadFileUsingHttp_FileNotFound(t *testing.T) { options := &DownloadOptions{ CreateDirs: true, OverwriteExist: true, + Timeout: 10 * time.Second, } _, err := DownloadFileUsingHttp("GET", server.URL+"/not-found", nil, nil, nil, options) @@ -193,7 +196,7 @@ func TestDownloadFileUsingHttp_ResumeDownload(t *testing.T) { filePath := filepath.Join(tempDir, "resume-test.txt") // Create partial file - err := os.WriteFile(filePath, []byte("Hello"), 0644) + err := os.WriteFile(filePath, []byte("Hello"), 0600) require.NoError(t, err) options := &DownloadOptions{ @@ -201,6 +204,7 @@ func TestDownloadFileUsingHttp_ResumeDownload(t *testing.T) { CreateDirs: true, OverwriteExist: true, ResumeDownload: true, + Timeout: 10 * time.Second, } result, err := DownloadFileUsingHttp("GET", server.URL+"/resume", nil, nil, nil, options) @@ -239,6 +243,7 @@ func TestDownloadFileUsingHttp_ProgressCallback(t *testing.T) { total int64 }{downloaded, total}) }, + Timeout: 10 * time.Second, } result, err := DownloadFileUsingHttp("GET", server.URL+"/progress", nil, nil, nil, options) @@ -274,6 +279,7 @@ func TestDownloadFileUsingHttp_CustomHeaders(t *testing.T) { "X-Custom-Header": "custom-value", "Accept": "application/json", // Should override default }, + Timeout: 10 * time.Second, } result, err := DownloadFileUsingHttp("GET", server.URL+"/headers", nil, nil, nil, options) @@ -293,6 +299,7 @@ func TestDownloadFileUsingHttp_UnsupportedHTTPVerb(t *testing.T) { options := &DownloadOptions{ CreateDirs: true, OverwriteExist: true, + Timeout: 10 * time.Second, } _, err := DownloadFileUsingHttp("INVALID", server.URL+"/test", nil, nil, nil, options) @@ -312,7 +319,7 @@ func TestDownloadFileUsingHttp_FileExistsNoOverwrite(t *testing.T) { existingFile := filepath.Join(tempDir, "existing.txt") // Create existing file - err := os.WriteFile(existingFile, []byte("Existing content"), 0644) + err := os.WriteFile(existingFile, []byte("Existing content"), 0600) require.NoError(t, err) options := &DownloadOptions{ @@ -320,6 +327,7 @@ func TestDownloadFileUsingHttp_FileExistsNoOverwrite(t *testing.T) { CreateDirs: true, OverwriteExist: false, // Don't overwrite ResumeDownload: false, + Timeout: 10 * time.Second, } _, err = DownloadFileUsingHttp("GET", server.URL+"/test", nil, nil, nil, options) @@ -328,7 +336,7 @@ func TestDownloadFileUsingHttp_FileExistsNoOverwrite(t *testing.T) { assert.Contains(t, err.Error(), "file already exists") // Verify original content is preserved - content, err := os.ReadFile(existingFile) + content, err := os.ReadFile(filepath.Clean(existingFile)) require.NoError(t, err) assert.Equal(t, "Existing content", string(content)) } diff --git a/shared-lib/git/client.go b/shared-lib/git/client.go index 55c9cabc..b6d89631 100644 --- a/shared-lib/git/client.go +++ b/shared-lib/git/client.go @@ -30,7 +30,7 @@ func NewClient(auth *Auth, url, branchOrTagName string, outputPath *string) (*Cl if _, err := os.Stat(*outputPath); err != nil { if os.IsNotExist(err) { // Try to create the directory if it doesn't exist - if err := os.MkdirAll(*outputPath, 0755); err != nil { + if err := os.MkdirAll(*outputPath, 0750); err != nil { return nil, fmt.Errorf("output path does not exist and cannot be created: %w", err) } } else { @@ -49,11 +49,11 @@ func NewClient(auth *Auth, url, branchOrTagName string, outputPath *string) (*Cl // Check if the directory is writable testFile := filepath.Join(*outputPath, ".write_test") - if file, err := os.Create(testFile); err != nil { + if file, err := os.Create(filepath.Clean(testFile)); err != nil { return nil, fmt.Errorf("output path is not writable: %w", err) } else { file.Close() - os.Remove(testFile) // Clean up test file + _ = os.Remove(testFile) // Clean up test file } // Convert to absolute path for consistency diff --git a/shared-lib/git/clone.go b/shared-lib/git/clone.go index 16e0e7a6..4d45b63e 100644 --- a/shared-lib/git/clone.go +++ b/shared-lib/git/clone.go @@ -59,7 +59,7 @@ func (client *Client) Clone(outputPath *string) (string, error) { } // Ensure directory exists, else it should be created with proper writable permissions - if err := os.MkdirAll(tempDir, 0755); err != nil { + if err := os.MkdirAll(tempDir, 0750); err != nil { return "", fmt.Errorf("failed to create temp directory: %w", err) } cloneDir := filepath.Join(tempDir, repoName) diff --git a/shared-lib/http/request.go b/shared-lib/http/request.go index 43f979c4..ebe118f8 100644 --- a/shared-lib/http/request.go +++ b/shared-lib/http/request.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -13,33 +14,28 @@ import ( ) // NewGetRequest creates a new GET HTTP request with authentication and query parameters -func NewGetRequest(url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { - // Build URL with query parameters +func NewGetRequest(ctx context.Context, url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { finalURL, err := buildURLWithParams(url, queryParams) if err != nil { return nil, fmt.Errorf("failed to build URL with parameters: %w", err) } - // Create the request - req, err := http.NewRequest("GET", finalURL, nil) + // Use NewRequestWithContext + req, err := http.NewRequestWithContext(ctx, "GET", finalURL, nil) if err != nil { return nil, fmt.Errorf("failed to create GET request: %w", err) } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewPostRequest creates a new POST HTTP request with authentication and body -func NewPostRequest(url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { - // Prepare request body +func NewPostRequest(ctx context.Context, url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { var bodyReader io.Reader var err error @@ -50,33 +46,28 @@ func NewPostRequest(url string, auth *auth.AuthConfig, body interface{}, content } } - // Create the request - req, err := http.NewRequest("POST", url, bodyReader) + // Use NewRequestWithContext + req, err := http.NewRequestWithContext(ctx, "POST", url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create POST request: %w", err) } - // Set content type if contentType != "" { req.Header.Set("Content-Type", contentType) } else if body != nil { req.Header.Set("Content-Type", "application/json") } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewPutRequest creates a new PUT HTTP request with authentication and body -func NewPutRequest(url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { - // Prepare request body +func NewPutRequest(ctx context.Context, url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { var bodyReader io.Reader var err error @@ -87,33 +78,27 @@ func NewPutRequest(url string, auth *auth.AuthConfig, body interface{}, contentT } } - // Create the request - req, err := http.NewRequest("PUT", url, bodyReader) + req, err := http.NewRequestWithContext(ctx, "PUT", url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create PUT request: %w", err) } - // Set content type if contentType != "" { req.Header.Set("Content-Type", contentType) } else if body != nil { req.Header.Set("Content-Type", "application/json") } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewPatchRequest creates a new PATCH HTTP request with authentication and body -func NewPatchRequest(url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { - // Prepare request body +func NewPatchRequest(ctx context.Context, url string, auth *auth.AuthConfig, body interface{}, contentType string) (*http.Request, error) { var bodyReader io.Reader var err error @@ -124,96 +109,77 @@ func NewPatchRequest(url string, auth *auth.AuthConfig, body interface{}, conten } } - // Create the request - req, err := http.NewRequest("PATCH", url, bodyReader) + req, err := http.NewRequestWithContext(ctx, "PATCH", url, bodyReader) if err != nil { return nil, fmt.Errorf("failed to create PATCH request: %w", err) } - // Set content type if contentType != "" { req.Header.Set("Content-Type", contentType) } else if body != nil { req.Header.Set("Content-Type", "application/json") } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewDeleteRequest creates a new DELETE HTTP request with authentication and optional query parameters -func NewDeleteRequest(url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { - // Build URL with query parameters +func NewDeleteRequest(ctx context.Context, url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { finalURL, err := buildURLWithParams(url, queryParams) if err != nil { return nil, fmt.Errorf("failed to build URL with parameters: %w", err) } - // Create the request - req, err := http.NewRequest("DELETE", finalURL, nil) + req, err := http.NewRequestWithContext(ctx, "DELETE", finalURL, nil) if err != nil { return nil, fmt.Errorf("failed to create DELETE request: %w", err) } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewHeadRequest creates a new HEAD HTTP request with authentication and query parameters -func NewHeadRequest(url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { - // Build URL with query parameters +func NewHeadRequest(ctx context.Context, url string, auth *auth.AuthConfig, queryParams map[string]interface{}) (*http.Request, error) { finalURL, err := buildURLWithParams(url, queryParams) if err != nil { return nil, fmt.Errorf("failed to build URL with parameters: %w", err) } - // Create the request - req, err := http.NewRequest("HEAD", finalURL, nil) + req, err := http.NewRequestWithContext(ctx, "HEAD", finalURL, nil) if err != nil { return nil, fmt.Errorf("failed to create HEAD request: %w", err) } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } // NewOptionsRequest creates a new OPTIONS HTTP request with authentication -func NewOptionsRequest(url string, auth *auth.AuthConfig) (*http.Request, error) { - // Create the request - req, err := http.NewRequest("OPTIONS", url, nil) +func NewOptionsRequest(ctx context.Context, url string, auth *auth.AuthConfig) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, "OPTIONS", url, nil) if err != nil { return nil, fmt.Errorf("failed to create OPTIONS request: %w", err) } - // Apply authentication if err := applyAuthentication(req, auth); err != nil { return nil, fmt.Errorf("failed to apply authentication: %w", err) } - // Set default headers setDefaultHeaders(req) - return req, nil } diff --git a/shared-lib/oci/client.go b/shared-lib/oci/client.go index 27bc69e0..64a1ecb2 100644 --- a/shared-lib/oci/client.go +++ b/shared-lib/oci/client.go @@ -107,19 +107,17 @@ func (c *Client) setupRemoteOptions() error { // Configure TLS settings if c.config.Insecure { transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, + InsecureSkipVerify: true, // #nosec G402 -- intentional and safe skipping } } // Add custom CA bundle if provided if len(c.config.CABundle) > 0 { - // TODO: Implement custom CA bundle loading - // This would require creating a custom cert pool + return fmt.Errorf("custom ca bundle for oci client is not supported yet") } // Add client certificate if provided if len(c.config.ClientCert) > 0 && len(c.config.ClientKey) > 0 { - // TODO: Implement client certificate loading - // This would require parsing the cert and key + return fmt.Errorf("custom client cert bundle for oci client is not supported yet") } c.remoteOpts = append(c.remoteOpts, remote.WithTransport(transport)) diff --git a/shared-lib/workloads/compose.go b/shared-lib/workloads/compose.go index 53a2c7a8..4ff50813 100644 --- a/shared-lib/workloads/compose.go +++ b/shared-lib/workloads/compose.go @@ -124,7 +124,7 @@ func NewDockerComposeClient(params DockerConnectivityParams, workingDir string) // Create Compose API service with CLI composeAPI := compose.NewComposeService(cli) - if err := os.MkdirAll(workingDir, 0755); err != nil { + if err := os.MkdirAll(workingDir, 0750); err != nil { return nil, fmt.Errorf("failed to create working directory: %w", err) } @@ -247,6 +247,9 @@ func (c *DockerComposeClient) GetComposeStatus(ctx context.Context, composeFile } project, err := c.loadComposeProject(ctx, projectName, composeFile, nil) + if err != nil { + return nil, err + } // Get project containers containers, err := c.composeAPI.Ps(ctx, projectName, api.PsOptions{ @@ -360,6 +363,7 @@ func (c *DockerComposeClient) FetchComposeFileFromURL(ctx context.Context, url s CreateDirs: true, OverwriteExist: true, ResumeDownload: false, + Timeout: time.Second * 30, ProgressCallback: func(downloaded, total int64) { fmt.Printf("\nTotal: %d, Downloaded: %d", total, downloaded) }, @@ -399,9 +403,11 @@ func (c *DockerComposeClient) forceCleanupProject(ctx context.Context, projectNa for _, containerID := range containersToRemove { // Stop first (ignore errors) timeout := 5 - c.dockerClient.ContainerStop(ctx, containerID, container.StopOptions{ + if err := c.dockerClient.ContainerStop(ctx, containerID, container.StopOptions{ Timeout: &timeout, - }) + }); err != nil { + fmt.Printf("error caught while stopping container: %s, err: %s", containerID, err.Error()) + } // Force remove if err := c.dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{ @@ -418,7 +424,7 @@ func (c *DockerComposeClient) forceCleanupProject(ctx context.Context, projectNa } func (c *DockerComposeClient) ExtractContent(composeFilename string) ([]byte, error) { - fileHandler, err := os.Open(composeFilename) + fileHandler, err := os.Open(filepath.Clean(composeFilename)) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } diff --git a/shared-lib/workloads/compose_test.go b/shared-lib/workloads/compose_test.go index bd5579fb..42ebbc01 100644 --- a/shared-lib/workloads/compose_test.go +++ b/shared-lib/workloads/compose_test.go @@ -29,5 +29,5 @@ func TestFetchComposeFileFromURL(t *testing.T) { t.Fatal(err) } - log.Println("compose file content", string(data)) + log.Println("compose file content", (data)) } diff --git a/shared-lib/workloads/dockerCliClient.go b/shared-lib/workloads/dockerCliClient.go index 774dd49e..4b8b6914 100644 --- a/shared-lib/workloads/dockerCliClient.go +++ b/shared-lib/workloads/dockerCliClient.go @@ -63,7 +63,7 @@ func NewDockerComposeCliClient(params DockerConnectivityParams, workingDir strin } // Create working directory - if err := os.MkdirAll(workingDir, 0755); err != nil { + if err := os.MkdirAll(workingDir, 0750); err != nil { return nil, fmt.Errorf("failed to create working directory: %w", err) } @@ -272,9 +272,7 @@ func (c *DockerComposeCliClient) RemoveCompose(ctx context.Context, projectName // Clean up project directory projectDir := filepath.Join(c.workingDir, projectName) - os.RemoveAll(projectDir) - - return nil + return os.RemoveAll(projectDir) } func (c *DockerComposeCliClient) GetComposeStatus(ctx context.Context, composeFile string, projectName string) (*ComposeStatus, error) { @@ -504,17 +502,23 @@ func (c *DockerComposeCliClient) generateAbsProjectFilepath(projectName string) } // fetchComposeFileFromURL - simplified version using io.ReadAll -func (c *DockerComposeCliClient) fetchComposeFileFromURL(ctx context.Context, url string, projectName string) (string, error) { +func (c *DockerComposeCliClient) fetchComposeFileFromURL(_ context.Context, url string, projectName string) (string, error) { // Create request with context - downloadResult, err := file.DownloadFileUsingHttp("GET", url, nil, nil, nil, &file.DownloadOptions{ - OutputPath: c.generateAbsProjectFilepath(projectName), - CreateDirs: true, - OverwriteExist: true, - ResumeDownload: false, - ProgressCallback: func(downloaded, total int64) { - fmt.Printf("\nTotal: %d, Downloaded: %d", total, downloaded) - }, - }) + downloadResult, err := file.DownloadFileUsingHttp( + "GET", + url, + nil, + nil, + nil, + &file.DownloadOptions{ + OutputPath: c.generateAbsProjectFilepath(projectName), + CreateDirs: true, + OverwriteExist: true, + ResumeDownload: false, + ProgressCallback: func(downloaded, total int64) { + fmt.Printf("\nTotal: %d, Downloaded: %d", total, downloaded) + }, + }) if err != nil { return "", fmt.Errorf("failed to download file: %w", err) } diff --git a/shared-lib/workloads/helm.go b/shared-lib/workloads/helm.go index ef5033b6..7ff5ff9c 100644 --- a/shared-lib/workloads/helm.go +++ b/shared-lib/workloads/helm.go @@ -284,7 +284,7 @@ func (c *HelmClient) InstallChart(ctx context.Context, releaseName, chart, names } // Traditional chart installation - chartPath, err := install.ChartPathOptions.LocateChart(chart, c.settings) + chartPath, err := install.LocateChart(chart, c.settings) if err != nil { return &HelmError{ Type: ErrorTypeChart, @@ -383,7 +383,7 @@ func (c *HelmClient) InstallChartWithDryRun(ctx context.Context, releaseName, ch install.Version = revision install.DryRun = true - chartPath, err := install.ChartPathOptions.LocateChart(chart, c.settings) + chartPath, err := install.LocateChart(chart, c.settings) if err != nil { return "", &HelmError{ Type: ErrorTypeChart, @@ -458,7 +458,7 @@ func (c *HelmClient) UpdateChart(ctx context.Context, name, chart, namespace str } // Traditional chart upgrade - chartPath, err := upgrade.ChartPathOptions.LocateChart(chart, c.settings) + chartPath, err := upgrade.LocateChart(chart, c.settings) if err != nil { return &HelmError{ Type: ErrorTypeChart,