├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── docu-deploy.yml │ ├── docu-test-deploy.yml │ ├── goreleaser.yml │ ├── lint.yml │ ├── scorecard.yml │ ├── snyk.yml │ └── vuln.yml ├── .gitignore ├── .goreleaser.yml ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── common.go ├── common_test.go ├── create.go ├── destroy.go ├── init.go ├── list.go ├── root.go ├── ssh.go └── version.go ├── docs ├── .gitignore ├── README.md ├── docs │ ├── deployments.md │ ├── getting-started.md │ ├── index.md │ ├── markdown-page.md │ └── templates.md ├── docusaurus.config.ts ├── package-lock.json ├── package.json ├── sidebars.ts ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── get.sh │ │ ├── get_beta.sh │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── style.css ├── static │ ├── .nojekyll │ ├── CNAME │ └── img │ │ ├── docusaurus-social-card.jpg │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── onkube.svg │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg └── tsconfig.json ├── go.mod ├── go.sum ├── gon ├── config-amd64.json └── config-arm64.json ├── internal ├── cloud │ ├── aws.go │ ├── azure.go │ ├── cloud.go │ ├── gcp.go │ └── hetzner.go ├── domain │ ├── cloudflare.go │ └── domain.go ├── files │ ├── apply_dir.sh │ ├── docker-compose.yml │ ├── docker.sh │ ├── embed.go │ └── init │ │ ├── aws.yaml │ │ ├── azure.yaml │ │ ├── gcp.yaml │ │ ├── hetzner.yaml │ │ └── onctl.yaml ├── provideraws │ └── common.go ├── providerazure │ └── common.go ├── providergcp │ └── common.go ├── providerhtz │ └── common.go ├── rand │ ├── rand.go │ └── rand_test.go └── tools │ ├── cicd.go │ ├── cloud-init.go │ ├── common.go │ ├── deploy.go │ ├── puppet │ └── puppet.go │ ├── remote-run.go │ ├── remote-run_test.go │ ├── scp.go │ ├── ssh.go │ ├── subnets.go │ └── vpcs.go ├── main.go ├── test-compose.sh └── upload.sh /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:jammy 2 | RUN apt-get update && apt-get install git curl vim golang -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "customizations": { 3 | "codespaces": { 4 | "repositories": { 5 | "cdalar/parampiper": { 6 | "permissions": "write-all" 7 | }, 8 | "cdalar/onctl-templates": { 9 | "permissions": "write-all" 10 | }, 11 | "cdalar/workspace": { 12 | "permissions": "write-all" 13 | }, 14 | "cdalar/kw-pv-policy": { 15 | "permissions": "write-all" 16 | } 17 | } 18 | } 19 | }, 20 | "image":"mcr.microsoft.com/devcontainers/universal:2" 21 | // "build": { "dockerfile": "Dockerfile" } 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | paths-ignore: 10 | - 'docs/**' 11 | pull_request: 12 | branches: [main] 13 | paths-ignore: 14 | - 'docs/**' 15 | schedule: 16 | - cron: '0 10 * * 1' # run "At 10:00 on Monday" 17 | workflow_call: 18 | inputs: 19 | skipTests: 20 | description: 'Skip tests, useful when there is a dedicated CI job for tests' 21 | default: false 22 | required: false 23 | type: boolean 24 | 25 | env: 26 | AWS_REGION: ${{ secrets.AWS_REGION }} 27 | HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }} 28 | 29 | jobs: 30 | run: 31 | name: Build 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 5 34 | strategy: 35 | fail-fast: true 36 | matrix: 37 | go: ['stable'] 38 | 39 | steps: 40 | - name: Check out code 41 | uses: actions/checkout@v4 42 | 43 | - name: Install Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version: ${{ matrix.go }} 47 | check-latest: true 48 | 49 | - name: Go Format 50 | run: gofmt -s -w . && git diff --exit-code 51 | 52 | - name: Go Tidy 53 | run: go mod tidy && git diff --exit-code 54 | 55 | - name: Go Vet 56 | run: go vet ./... 57 | 58 | - name: Go Mod 59 | run: go mod download 60 | 61 | - name: Go Mod Verify 62 | run: go mod verify 63 | 64 | - name: Go Generate 65 | run: go generate ./... && git diff --exit-code 66 | 67 | - name: Go Build 68 | run: go build -o /dev/null ./... 69 | 70 | - name: Go Compile Tests 71 | if: ${{ inputs.skipTests }} 72 | run: go test -exec /bin/true ./... 73 | 74 | - name: Go Test 75 | if: ${{ !inputs.skipTests }} 76 | run: go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./... 77 | 78 | - name: Go Benchmark 79 | if: ${{ !inputs.skipTests }} 80 | run: go test -v -shuffle=on -run=- -bench=. -benchtime=1x ./... 81 | 82 | # - name: Upload Coverage 83 | # if: ${{ !inputs.skipTests }} 84 | # uses: codecov/codecov-action@v4 85 | # continue-on-error: true 86 | # with: 87 | # token: ${{secrets.CODECOV_TOKEN}} 88 | # file: ./coverage.txt 89 | # fail_ci_if_error: false -------------------------------------------------------------------------------- /.github/workflows/docu-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | # Review gh actions docs if you want to further define triggers, paths, etc 9 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on 10 | 11 | jobs: 12 | build: 13 | name: Build Docusaurus 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | shell: bash 18 | working-directory: ./docs 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | cache: npm 27 | cache-dependency-path: docs/package-lock.json 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | - name: Build website 32 | run: npm run build 33 | 34 | - name: Upload Build Artifact 35 | uses: actions/upload-pages-artifact@v3 36 | with: 37 | path: docs/build 38 | 39 | deploy: 40 | name: Deploy to GitHub Pages 41 | needs: build 42 | defaults: 43 | run: 44 | shell: bash 45 | working-directory: ./docs 46 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 47 | permissions: 48 | pages: write # to deploy to Pages 49 | id-token: write # to verify the deployment originates from an appropriate source 50 | 51 | # Deploy to the github-pages environment 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/docu-test-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test deployment 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - main 8 | # Review gh actions docs if you want to further define triggers, paths, etc 9 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on 10 | 11 | jobs: 12 | test-deploy: 13 | name: Test deployment 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | shell: bash 18 | working-directory: ./docs 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | cache: npm 27 | cache-dependency-path: docs/package-lock.json 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | - name: Test build website 32 | run: npm run build -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | 10 | permissions: 11 | contents: write 12 | packages: write 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: macos-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '1.21' 26 | check-latest: true 27 | cache: true 28 | - name: Import GPG Key 29 | run: | 30 | echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --import --no-tty --batch 31 | gpg --list-secret-keys 32 | - name: Import Code-Signing Certificates 33 | uses: apple-actions/import-codesign-certs@v3 34 | with: 35 | # The certificates in a PKCS12 file encoded as a base64 string 36 | p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} 37 | # The password used to import the PKCS12 file. 38 | p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} 39 | - name: install brew and gon 40 | # /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 41 | run: | 42 | brew tap mitchellh/gon 43 | brew install mitchellh/gon/gon 44 | - name: Run GoReleaser 45 | uses: goreleaser/goreleaser-action@v6.3.0 46 | with: 47 | distribution: goreleaser 48 | version: latest 49 | args: release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GH_TOKEN }} 52 | AC_PASSWORD: ${{ secrets.AC_PASSWORD }} 53 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 54 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 55 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | paths-ignore: 10 | - 'docs/**' 11 | pull_request: 12 | branches: [main] 13 | paths-ignore: 14 | - 'docs/**' 15 | workflow_call: 16 | 17 | jobs: 18 | run: 19 | name: Lint 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 5 22 | strategy: 23 | fail-fast: true 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Install Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: 'stable' 33 | check-latest: true 34 | 35 | - name: Lint 36 | uses: golangci/golangci-lint-action@v6.5.2 37 | with: 38 | version: latest 39 | args: --timeout 5m 40 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '43 2 * * 1' 14 | push: 15 | branches: [ "main" ] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | # Uncomment the permissions below if installing in a private repository. 30 | # contents: read 31 | # actions: read 32 | 33 | steps: 34 | - name: "Checkout code" 35 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 36 | with: 37 | persist-credentials: false 38 | 39 | - name: "Run analysis" 40 | uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 41 | with: 42 | results_file: results.sarif 43 | results_format: sarif 44 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 45 | # - you want to enable the Branch-Protection check on a *public* repository, or 46 | # - you are installing Scorecard on a *private* repository 47 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 48 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 49 | 50 | # Public repositories: 51 | # - Publish results to OpenSSF REST API for easy access by consumers 52 | # - Allows the repository to include the Scorecard badge. 53 | # - See https://github.com/ossf/scorecard-action#publishing-results. 54 | # For private repositories: 55 | # - `publish_results` will always be set to `false`, regardless 56 | # of the value entered here. 57 | publish_results: true 58 | 59 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 60 | # format to the repository Actions tab. 61 | # - name: "Upload artifact" 62 | # uses: actions/upload-artifact@97a0fba1372883ab732affbe8f94b823f91727db # v3.pre.node20 63 | # with: 64 | # name: SARIF file 65 | # path: results.sarif 66 | # retention-days: 5 67 | 68 | # Upload the results to GitHub's code scanning dashboard (optional). 69 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 70 | # - name: "Upload to code-scanning" 71 | # uses: github/codeql-action/upload-sarif@v3 72 | # with: 73 | # sarif_file: results.sarif 74 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yml: -------------------------------------------------------------------------------- 1 | name: snyk 2 | 3 | on: pull_request_target 4 | 5 | permissions: 6 | contents: read 7 | security-events: write 8 | 9 | jobs: 10 | security: 11 | runs-on: ubuntu-latest 12 | # if: github.actor != 'dependabot[bot]' 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Run Snyk to check for vulnerabilities 16 | uses: snyk/actions/golang@master 17 | continue-on-error: true # To make sure that SARIF upload gets called 18 | env: 19 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 20 | with: 21 | args: --severity-threshold=high --sarif-file-output=snyk.sarif 22 | - name: Upload result to GitHub Code Scanning 23 | uses: github/codeql-action/upload-sarif@v3 24 | with: 25 | sarif_file: snyk.sarif 26 | -------------------------------------------------------------------------------- /.github/workflows/vuln.yml: -------------------------------------------------------------------------------- 1 | name: vuln 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | schedule: 12 | - cron: '0 10 * * 1' # run "At 10:00 on Monday" 13 | workflow_call: 14 | 15 | jobs: 16 | run: 17 | name: Vuln 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | strategy: 21 | fail-fast: true 22 | 23 | steps: 24 | - name: Install Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: 'stable' 28 | check-latest: true 29 | 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Install govulncheck 34 | run: go install golang.org/x/vuln/cmd/govulncheck@latest 35 | 36 | - name: Run govulncheck 37 | run: govulncheck -test ./... 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | onctl.exe 3 | onctl 4 | onctl-deploy.json 5 | onctl-linux 6 | ip.txt 7 | tmp/* 8 | .env* 9 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: onctl 3 | 4 | signs: 5 | - artifacts: checksum 6 | 7 | before: 8 | hooks: 9 | # You may remove this if you don't use go modules. 10 | - go mod tidy 11 | # you may remove this if you don't need go generate 12 | # - go generate ./... 13 | 14 | builds: 15 | - binary: onctl 16 | id: onctl-linux 17 | env: 18 | - CGO_ENABLED=0 19 | goos: 20 | - linux 21 | goarch: 22 | - amd64 23 | - arm64 24 | ldflags: 25 | - -w -s -X 'github.com/cdalar/onctl/cmd.Version=v{{.Version}}-{{.ShortCommit}}' 26 | 27 | - binary: onctl 28 | id: onctl-windows 29 | env: 30 | - CGO_ENABLED=0 31 | goos: 32 | - windows 33 | goarch: 34 | - amd64 35 | ldflags: 36 | - -w -s -X 'github.com/cdalar/onctl/cmd.Version=v{{.Version}}-{{.ShortCommit}}' 37 | 38 | - binary: onctl 39 | id: onctl 40 | env: 41 | - CGO_ENABLED=0 42 | goos: 43 | - darwin 44 | goarch: 45 | - amd64 46 | - arm64 # M1 Chip 47 | ldflags: 48 | - -w -s -X 'github.com/cdalar/onctl/cmd.Version=v{{.Version}}-{{.ShortCommit}}' 49 | # hooks: 50 | # post: ["gon gon/config-{{.Arch}}.json"] 51 | 52 | 53 | archives: 54 | - id: repl 55 | name_template: "{{ .ProjectName }}-{{.Os}}-{{.Arch}}" 56 | formats: ['tar.gz'] 57 | format_overrides: 58 | - goos: windows 59 | formats: ['zip'] 60 | # files: 61 | # - non-existent* 62 | 63 | checksum: 64 | name_template: "checksums.txt" 65 | snapshot: 66 | version_template: "{{ incpatch .Version }}-next" 67 | changelog: 68 | sort: asc 69 | filters: 70 | exclude: 71 | - "^docs:" 72 | - "^test:" 73 | 74 | release: 75 | github: 76 | owner: cdalar 77 | name: onctl 78 | prerelease: auto 79 | 80 | brews: 81 | - repository: 82 | owner: cdalar 83 | name: homebrew-tap 84 | description: "onctl" 85 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "test ls", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "debug", 12 | "program": "${workspaceRoot}/main.go", 13 | "env": { 14 | "ONCTL_LOG": "DEBUG", 15 | "ONCTL_CLOUD": "hetzner" 16 | }, 17 | "args": [ 18 | "ls" 19 | ], 20 | "cwd": "${workspaceRoot}", 21 | "showLog": true 22 | }, 23 | { 24 | "name": "Launch", 25 | "type": "go", 26 | "request": "launch", 27 | "mode": "debug", 28 | "program": "${workspaceRoot}/main.go", 29 | "env": { 30 | "ONCTL_LOG": "DEBUG", 31 | "ONCTL_CLOUD": "azure" 32 | }, 33 | "args": [ 34 | "up", 35 | "-i", 36 | "wireguard/vpn.sh" 37 | ], 38 | "cwd": "${workspaceRoot}", 39 | "showLog": true 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.testEnvFile": "${workspaceFolder}/test.env", 3 | "yaml.schemas": { 4 | "https://json.schemastore.org/github-workflow.json": "${workspaceFolder}/.github/workflows/goreleaser.yml" 5 | }, 6 | "svg.preview.background": "transparent" 7 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | cemal@dalar.net 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO_CMD=go 2 | BINARY_NAME=onctl 3 | 4 | # Mark targets as phony (not files) 5 | .PHONY: all build clean run test 6 | 7 | # Default target 8 | all: build 9 | 10 | # Build the binary 11 | build: 12 | export CGO_ENABLED=0 13 | $(GO_CMD) mod tidy 14 | $(GO_CMD) build -ldflags="-w -s -X 'github.com/cdalar/onctl/cmd.Version=`git rev-parse HEAD | cut -c1-7`' \ 15 | -X 'github.com/cdalar/onctl/cmd.BuildTime=`date -u '+%Y-%m-%d %H:%M:%S'`' \ 16 | -X 'github.com/cdalar/onctl/cmd.GoVersion=`go version`'" \ 17 | -o $(BINARY_NAME) main.go 18 | 19 | # Clean up the binary 20 | clean: 21 | rm $(BINARY_NAME) 22 | 23 | # Test the application 24 | test: 25 | $(GO_CMD) test ./... 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Onctl 2 | 3 | `onctl` is a tool to manage virtual machines in multi-cloud. 4 | 5 | Check 🌍 https://docs.onctl.io for detailed documentation 6 | 7 | [![build](https://github.com/cdalar/onctl/actions/workflows/build.yml/badge.svg)](https://github.com/cdalar/onctl/actions/workflows/build.yml) 8 | [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cdalar/onctl/badge)](https://scorecard.dev/viewer/?uri=github.com/cdalar/onctl) 9 | [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/10052/badge)](https://www.bestpractices.dev/projects/10052) 10 | [![Go Report Card](https://goreportcard.com/badge/github.com/cdalar/onctl)](https://goreportcard.com/report/github.com/cdalar/onctl) 11 | [![CodeQL](https://github.com/cdalar/onctl/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/cdalar/onctl/actions/workflows/github-code-scanning/codeql) 12 | [![codecov](https://codecov.io/gh/cdalar/onctl/graph/badge.svg?token=7VU7H1II09)](https://codecov.io/gh/cdalar/onctl) 13 | [![Github All Releases](https://img.shields.io/github/downloads/cdalar/onctl/total.svg)]() 14 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/cdalar/onctl?sort=semver) 15 | 16 | 17 | ## What onctl brings 18 | 19 | - 🌍 Simple intuitive CLI to run VMs in seconds. 20 | - ⛅️ Supports multi cloud providers (aws, azure, gcp, hetzner, more coming soon...) 21 | - 🚀 Sets your public key and Gives you SSH access with `onctl ssh ` 22 | - ✨ Cloud-init support. Set your own cloud-init file `onctl up -n qwe --cloud-init ` 23 | - 🤖 Use ready to use templates to configure your vm. Check [onctl-templates](https://github.com/cdalar/onctl-templates) `onctl up -n qwe -a k3s/k3s-server.sh` 24 | - 🗂️ Use your custom local or http accessible scripts to configure your vm. `onctl ssh qwe -a ` 25 | 26 | ## Quick Start 27 | 28 | initialize project. this will create a `.onctl` directory. check configuration file and set as needed. 29 | ``` 30 | ❯ onctl init 31 | onctl environment initialized 32 | ``` 33 | 34 | export `ONCTL_CLOUD` to set Cloud Provider. 35 | ``` 36 | ❯ export ONCTL_CLOUD=hetzner 37 | ``` 38 | 39 | Be sure that credentials for that specific cloud provider is already set. 40 | If you already use cloud provider CLI. They're already . ex. `az`, `aws`, `hcloud` 41 | ``` 42 | ❯ echo $HCLOUD_TOKEN 43 | ``` 44 | 45 | Create VM. 46 | ``` 47 | ❯ onctl up -n onctl-test 48 | Using: hetzner 49 | Creating SSHKey: onctl-42da32a9... 50 | SSH Key already exists (onctl-42da32a9) 51 | Starting server... 52 | Server IP: 168.119.58.112 53 | Vm started. 54 | ``` 55 | 56 | Ssh into VM. 57 | ``` 58 | ❯ onctl ssh onctl-test 59 | Using: hetzner 60 | Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64) 61 | . 62 | . 63 | . 64 | root@onctl-test:~# 65 | ``` 66 | 67 | ## Installation 68 | 69 | ### MacOS 70 | 71 | ```zsh 72 | brew install cdalar/tap/onctl 73 | ``` 74 | 75 | ### Linux 76 | 77 | ```bash 78 | curl -sLS https://www.onctl.com/get.sh | bash 79 | sudo install onctl /usr/local/bin/ 80 | ``` 81 | 82 | ### Windows 83 | 84 | - download windows binary from [releases page](https://github.com/cdalar/onctl/releases) 85 | - unzip and copy onctl.exe to a location in PATH 86 | 87 | # Enjoy ✅ 88 | 89 | ``` 90 | ❯ onctl 91 | onctl is a tool to manage cross platform resources in cloud 92 | 93 | Usage: 94 | onctl [command] 95 | 96 | Available Commands: 97 | completion Generate the autocompletion script for the specified shell 98 | create Create a VM 99 | destroy Destroy VM(s) 100 | help Help about any command 101 | init init onctl environment 102 | ls List VMs 103 | ssh Spawn an SSH connection to a VM 104 | version Print the version number of onctl 105 | 106 | Flags: 107 | -h, --help help for onctl 108 | 109 | Use "onctl [command] --help" for more information about a command. 110 | ``` 111 | 112 | ## Star History 113 | 114 | [![Star History Chart](https://api.star-history.com/svg?repos=cdalar/onctl&type=Date)](https://star-history.com/#cdalar/onctl&Date) 115 | -------------------------------------------------------------------------------- /cmd/common.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | "sync" 15 | "text/tabwriter" 16 | "text/template" 17 | "time" 18 | 19 | "github.com/aws/aws-sdk-go/service/ec2" 20 | "github.com/cdalar/onctl/internal/files" 21 | "github.com/cdalar/onctl/internal/tools" 22 | "github.com/gofrs/uuid/v5" 23 | "github.com/manifoldco/promptui" 24 | "github.com/spf13/viper" 25 | "k8s.io/apimachinery/pkg/util/duration" 26 | ) 27 | 28 | // TODO decomple viper and use onctlConfig instead 29 | // var onctlConfig map[string]interface{} 30 | 31 | func GenerateIDToken() uuid.UUID { 32 | u1, err := uuid.NewV4() 33 | if err != nil { 34 | log.Fatalf("failed to generate ID Token: %v", err) 35 | } 36 | log.Printf("[DEBUG] ID Token generated %v", u1) 37 | return u1 38 | } 39 | 40 | func ReadConfig(cloudProvider string) error { 41 | // Check current working directory 42 | dir, err := os.Getwd() 43 | if err != nil { 44 | return fmt.Errorf("failed to get working directory: %v", err) 45 | } 46 | 47 | localConfigPath := filepath.Join(dir, ".onctl") 48 | homeDir, err := os.UserHomeDir() 49 | if err != nil { 50 | return fmt.Errorf("failed to get home directory: %v", err) 51 | } 52 | homeConfigPath := filepath.Join(homeDir, ".onctl") 53 | 54 | log.Println("[DEBUG] Local Config Path:", localConfigPath) 55 | log.Println("[DEBUG] Home Config Path:", homeConfigPath) 56 | // Determine which directory to use 57 | var configDir string 58 | if _, err := os.Stat(localConfigPath); err == nil { 59 | configDir = localConfigPath 60 | log.Println("[DEBUG] Using local config directory") 61 | } else if _, err := os.Stat(homeConfigPath); err == nil { 62 | configDir = homeConfigPath 63 | log.Println("[DEBUG] Using home config directory") 64 | } else { 65 | return fmt.Errorf("no configuration directory found in current directory or home directory. Please run `onctl init` first") 66 | } 67 | 68 | // Set paths for general and cloud provider-specific config 69 | configFile := filepath.Join(configDir, cloudProvider+".yaml") 70 | log.Println("[DEBUG] Config File Path:", configFile) 71 | 72 | if _, err := os.Stat(configFile); os.IsNotExist(err) { 73 | return fmt.Errorf("no configuration file found for %s in %s", cloudProvider, configDir) 74 | } 75 | 76 | viper.SetConfigName("onctl") // General config 77 | viper.AddConfigPath(configDir) 78 | 79 | if err := viper.ReadInConfig(); err != nil { 80 | log.Printf("Failed to read general config: %v", err) 81 | } 82 | 83 | viper.SetConfigName(cloudProvider) // Specific config 84 | if err := viper.MergeInConfig(); err != nil { 85 | log.Printf("Failed to merge cloud provider config: %v", err) 86 | } 87 | 88 | log.Println("[DEBUG] Loaded Settings:", viper.AllSettings()) 89 | return nil 90 | } 91 | 92 | func getNameFromTags(tags []*ec2.Tag) string { 93 | for _, v := range tags { 94 | if *v.Key == "Name" { 95 | return *v.Value 96 | } 97 | } 98 | return "" 99 | } 100 | 101 | func durationFromCreatedAt(createdAt time.Time) string { 102 | return duration.HumanDuration(time.Since(createdAt)) 103 | } 104 | 105 | func TabWriter(res interface{}, tmpl string) { //nolint 106 | var funcs = template.FuncMap{"getNameFromTags": getNameFromTags} 107 | var funcs2 = template.FuncMap{"durationFromCreatedAt": durationFromCreatedAt} 108 | w := tabwriter.NewWriter(os.Stdout, 2, 2, 3, ' ', 0) 109 | tmp, err := template.New("test").Funcs(funcs).Funcs(funcs2).Parse(tmpl) 110 | if err != nil { 111 | log.Println(err) 112 | } 113 | err = tmp.Execute(w, res) 114 | if err != nil { 115 | log.Println(err) 116 | } 117 | w.Flush() 118 | } 119 | func PrettyPrint(v interface{}) (err error) { 120 | b, err := json.MarshalIndent(v, "", " ") 121 | if err == nil { 122 | fmt.Println(string(b)) 123 | } 124 | return 125 | } 126 | 127 | //lint:ignore U1000 will use this function in the future 128 | func yesNo() bool { 129 | prompt := promptui.Select{ 130 | Label: "Please confirm [y/N]", 131 | Items: []string{"Yes", "No"}, 132 | CursorPos: 1, 133 | } 134 | _, result, err := prompt.Run() 135 | if err != nil { 136 | log.Fatalf("Prompt failed %v\n", err) 137 | } 138 | return result == "Yes" 139 | } 140 | 141 | //lint:ignore U1000 will use this function in the future 142 | func openbrowser(url string) { 143 | var err error 144 | 145 | switch runtime.GOOS { 146 | case "linux": 147 | err = exec.Command("xdg-open", url).Start() 148 | case "windows": 149 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 150 | case "darwin": 151 | err = exec.Command("open", url).Start() 152 | default: 153 | err = fmt.Errorf("unsupported platform") 154 | } 155 | if err != nil { 156 | fmt.Println(err) 157 | } 158 | 159 | } 160 | 161 | func findFile(files []string) []string { 162 | var filePaths []string 163 | for _, file := range files { 164 | filePath := findSingleFile(file) 165 | filePaths = append(filePaths, filePath) 166 | } 167 | return filePaths 168 | } 169 | 170 | func findSingleFile(filename string) (filePath string) { 171 | if filename == "" { 172 | return "" 173 | } 174 | 175 | // Checking file in filesystem 176 | _, err := os.Stat(filename) 177 | if err == nil { // file found in filesystem 178 | return filename 179 | } else { 180 | log.Println("[DEBUG]", filename, "file not found in filesystem, trying to find in embeded files") 181 | } 182 | 183 | // file not found in filesystem, trying to find in embeded files 184 | fileContent, err := files.EmbededFiles.ReadFile(filename) 185 | if err == nil { 186 | log.Println("[DEBUG]", filename, "file found in embeded files") 187 | 188 | dir, err := os.MkdirTemp("", "onctl") 189 | if err != nil { 190 | log.Fatal(err) 191 | } 192 | 193 | file := filepath.Join(dir, filename) 194 | if err := os.WriteFile(file, fileContent, 0666); err != nil { 195 | log.Fatal(err) 196 | } 197 | 198 | return file 199 | 200 | } else { 201 | log.Println("[DEBUG]", filename, "not found in embeded files, trying to find in templates.onctl.com/") 202 | } 203 | 204 | // file not found in embeded files, trying to find in templates.onctl.com/ 205 | if filename[0:4] != "http" { 206 | filename = "https://templates.onctl.com/" + filename 207 | } 208 | 209 | resp, err := http.Get(filename) 210 | if err == nil && resp.StatusCode == 200 { 211 | log.Println("[DEBUG]", filename, "file found in templates.onctl.com/") 212 | 213 | defer resp.Body.Close() 214 | dir, err := os.MkdirTemp("", "onctl") 215 | if err != nil { 216 | log.Fatal(err) 217 | } 218 | 219 | fileBaseName := filepath.Base(filename) 220 | filePath := filepath.Join(dir, fileBaseName) 221 | fileContent, err := io.ReadAll(resp.Body) 222 | if err != nil { 223 | log.Fatal(err) 224 | } 225 | if err := os.WriteFile(filePath, fileContent, 0666); err != nil { 226 | log.Fatal(err) 227 | } 228 | 229 | return filePath 230 | } else { 231 | log.Println("[DEBUG]", filename, "not found in templates.onctl.com/") 232 | fmt.Println("Error: " + filename + " not found in (filesystem, embeded files and templates.onctl.com/)") 233 | os.Exit(1) 234 | } 235 | return "" 236 | } 237 | 238 | func getSSHKeyFilePaths(filename string) (publicKeyFile, privateKeyFile string) { 239 | 240 | home, err := os.UserHomeDir() 241 | if err != nil { 242 | log.Println(err) 243 | } 244 | 245 | if filename == "" { 246 | publicKeyFile = viper.GetString("ssh.publicKey") 247 | privateKeyFile = viper.GetString("ssh.privateKey") 248 | } else { 249 | // check if filename has .pub extension 250 | if filename[len(filename)-4:] == ".pub" { 251 | publicKeyFile = filename 252 | privateKeyFile = filename[:len(filename)-4] 253 | } else { 254 | privateKeyFile = filename 255 | publicKeyFile = filename + ".pub" 256 | } 257 | } 258 | 259 | // change ~ char with home directory 260 | publicKeyFile = strings.Replace(publicKeyFile, "~", home, 1) 261 | privateKeyFile = strings.Replace(privateKeyFile, "~", home, 1) 262 | 263 | log.Println("[DEBUG] publicKeyFile:", publicKeyFile) 264 | log.Println("[DEBUG] privateKeyFile:", privateKeyFile) 265 | if _, err := os.Stat(publicKeyFile); err != nil { 266 | log.Println("[DEBUG]", publicKeyFile, "Public key file not found") 267 | } 268 | 269 | if _, err := os.Stat(privateKeyFile); err != nil { 270 | log.Println("[DEBUG]", privateKeyFile, "Private key file not found") 271 | } 272 | 273 | return publicKeyFile, privateKeyFile 274 | } 275 | 276 | func ProcessUploadSlice(uploadSlice []string, remote tools.Remote) { 277 | if len(uploadSlice) > 0 { 278 | var wg sync.WaitGroup 279 | for _, dfile := range uploadSlice { 280 | wg.Add(1) 281 | go func(dfile string) { 282 | defer wg.Done() 283 | 284 | var localFile, remoteFile string 285 | // Split by colon to determine if a rename is required 286 | if strings.Contains(dfile, ":") { 287 | parts := strings.SplitN(dfile, ":", 2) 288 | localFile = parts[0] 289 | remoteFile = parts[1] 290 | } else { 291 | localFile = dfile 292 | remoteFile = filepath.Base(dfile) 293 | } 294 | 295 | log.Println("[DEBUG] localFile: " + localFile) 296 | log.Println("[DEBUG] remoteFile: " + remoteFile) 297 | 298 | fmt.Printf("Uploading file: %s -> %s\n", localFile, remoteFile) 299 | 300 | err := remote.SSHCopyFile(localFile, remoteFile) 301 | if err != nil { 302 | log.Printf("[ERROR] Failed to upload %s: %v", localFile, err) 303 | } 304 | }(dfile) 305 | } 306 | wg.Wait() // Wait for all goroutines to finish 307 | } 308 | } 309 | 310 | func ProcessDownloadSlice(downloadSlice []string, remote tools.Remote) { 311 | if len(downloadSlice) > 0 { 312 | var wg sync.WaitGroup 313 | for _, dfile := range downloadSlice { 314 | wg.Add(1) 315 | go func(dfile string) { 316 | defer wg.Done() 317 | 318 | var remoteFile, localFile string 319 | // Split by colon to determine if a rename is required 320 | if strings.Contains(dfile, ":") { 321 | parts := strings.SplitN(dfile, ":", 2) 322 | remoteFile = parts[0] 323 | localFile = parts[1] 324 | } else { 325 | remoteFile = dfile 326 | localFile = filepath.Base(dfile) 327 | } 328 | 329 | log.Printf("Downloading file: %s -> %s", remoteFile, localFile) 330 | 331 | err := remote.DownloadFile(remoteFile, localFile) 332 | if err != nil { 333 | log.Printf("[ERROR] Failed to download %s: %v", remoteFile, err) 334 | } 335 | }(dfile) 336 | } 337 | wg.Wait() // Wait for all goroutines to finish 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /cmd/common_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gofrs/uuid/v5" 12 | "k8s.io/apimachinery/pkg/util/duration" 13 | ) 14 | 15 | func TestGenerateIDToken(t *testing.T) { 16 | // Capture log output for validation 17 | var logOutput strings.Builder 18 | log.SetOutput(&logOutput) 19 | 20 | // Generate a UUID 21 | token := GenerateIDToken() 22 | 23 | // Validate the token is not nil 24 | if token == uuid.Nil { 25 | t.Fatalf("expected a valid UUID, got nil UUID") 26 | } 27 | 28 | // Validate that the log contains the expected debug message 29 | logContents := logOutput.String() 30 | expectedLogSubstring := "[DEBUG] ID Token generated" 31 | if !strings.Contains(logContents, expectedLogSubstring) { 32 | t.Fatalf("expected log to contain %q, got %q", expectedLogSubstring, logContents) 33 | } 34 | 35 | // Reset log output to default 36 | log.SetOutput(os.Stderr) 37 | } 38 | 39 | func TestDurationFromCreatedAt(t *testing.T) { 40 | now := time.Now() 41 | 42 | tests := []struct { 43 | name string 44 | createdAt time.Time 45 | expectedIn string // Partial string match for human-readable duration 46 | }{ 47 | { 48 | name: "Just now", 49 | createdAt: now, 50 | expectedIn: "0s", 51 | }, 52 | { 53 | name: "1 minute ago", 54 | createdAt: now.Add(-time.Minute), 55 | expectedIn: "1m", 56 | }, 57 | { 58 | name: "1 hour ago", 59 | createdAt: now.Add(-time.Hour), 60 | expectedIn: "1h", 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | result := durationFromCreatedAt(tt.createdAt) 67 | if !containsDurationString(result, tt.expectedIn) { 68 | t.Errorf("expected duration to contain %q, got %q", tt.expectedIn, result) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func containsDurationString(fullString, substring string) bool { 75 | return len(fullString) > 0 && len(substring) > 0 && len(duration.ShortHumanDuration(time.Second)) > 0 76 | } 77 | 78 | func TestTabWriter(t *testing.T) { 79 | // Test data 80 | data := struct { 81 | Name string 82 | Age int 83 | }{ 84 | Name: "John", 85 | Age: 30, 86 | } 87 | 88 | templateStr := "{{.Name}}\t{{.Age}}\n" 89 | 90 | // Redirect stdout to capture the output 91 | r, w, _ := os.Pipe() 92 | os.Stdout = w 93 | 94 | // Call TabWriter 95 | TabWriter(data, templateStr) 96 | 97 | // Close the writer and read the output 98 | w.Close() 99 | var buf bytes.Buffer 100 | _, err := buf.ReadFrom(r) 101 | if err != nil { 102 | t.Fatalf("failed to read from pipe: %v", err) 103 | } 104 | output := buf.String() 105 | 106 | // Reset stdout 107 | os.Stdout = os.Stderr 108 | 109 | // Validate the output 110 | expected := "John 30\n" 111 | if output != expected { 112 | t.Errorf("expected %q, got %q", expected, output) 113 | } 114 | } 115 | 116 | func TestPrettyPrint(t *testing.T) { 117 | // Test data 118 | data := map[string]string{"key": "value"} 119 | 120 | // Call PrettyPrint and capture stdout 121 | r, w, _ := os.Pipe() 122 | os.Stdout = w 123 | 124 | err := PrettyPrint(data) 125 | if err != nil { 126 | t.Fatalf("PrettyPrint returned an error: %v", err) 127 | } 128 | 129 | // Close the writer and read the output 130 | w.Close() 131 | var buf bytes.Buffer 132 | _, err = buf.ReadFrom(r) 133 | if err != nil { 134 | t.Fatalf("failed to read from pipe: %v", err) 135 | } 136 | output := buf.String() 137 | 138 | // Reset stdout 139 | os.Stdout = os.Stderr 140 | 141 | // Validate the output 142 | expected := "{\n \"key\": \"value\"\n}\n" 143 | if output != expected { 144 | t.Errorf("expected %q, got %q", expected, output) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /cmd/create.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/briandowns/spinner" 10 | "github.com/cdalar/onctl/internal/cloud" 11 | "github.com/cdalar/onctl/internal/domain" 12 | "github.com/cdalar/onctl/internal/tools" 13 | 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | // TODO: ? Struct for options. cmdCreateOptions 19 | // TODO: .env file support 20 | // TODO: remove initFile and implement ssh apply structure 21 | // TODO: ? Create Packages with cloud-init, apply Files, Variables. (cloud-init, apply, vars) 22 | 23 | type cmdCreateOptions struct { 24 | PublicKeyFile string 25 | ApplyFile []string 26 | DotEnvFile string 27 | Variables []string 28 | Vm cloud.Vm 29 | Domain string 30 | } 31 | 32 | var ( 33 | opt cmdCreateOptions 34 | ) 35 | 36 | func init() { 37 | createCmd.Flags().StringVarP(&opt.PublicKeyFile, "publicKey", "k", "", "Path to publicKey file (default: ~/.ssh/id_rsa))") 38 | createCmd.Flags().StringSliceVarP(&opt.ApplyFile, "apply-file", "a", []string{}, "bash script file(s) to run on remote") 39 | createCmd.Flags().StringSliceVarP(&downloadSlice, "download", "d", []string{}, "List of files to download") 40 | createCmd.Flags().StringSliceVarP(&uploadSlice, "upload", "u", []string{}, "List of files to upload") 41 | createCmd.Flags().StringVarP(&opt.Vm.Name, "name", "n", "", "vm name") 42 | createCmd.Flags().IntVarP(&opt.Vm.SSHPort, "ssh-port", "p", 22, "ssh port") 43 | createCmd.Flags().StringVarP(&opt.Vm.CloudInitFile, "cloud-init", "i", "", "cloud-init file") 44 | createCmd.Flags().StringVar(&opt.DotEnvFile, "dot-env", "", "dot-env (.env) file") 45 | createCmd.Flags().StringVar(&opt.Domain, "domain", "", "request a domain name for the VM") 46 | createCmd.Flags().StringSliceVarP(&opt.Variables, "vars", "e", []string{}, "Environment variables passed to the script") 47 | createCmd.SetUsageTemplate(createCmd.UsageTemplate() + ` 48 | Environment Variables: 49 | CLOUDFLARE_API_TOKEN Cloudflare API Token (required for --domain) 50 | CLOUDFLARE_ZONE_ID Cloudflare Zone ID (required for --domain) 51 | `) 52 | } 53 | 54 | var createCmd = &cobra.Command{ 55 | Use: "create", 56 | Aliases: []string{"start", "up"}, 57 | Short: "Create a VM", 58 | Long: `Create a VM with the specified options and run the cloud-init file on the remote.`, 59 | Example: ` # Create a VM with docker installed and set ssh on port 443 60 | onctl create -n onctl-test -a docker/docker.sh -i cloud-init/ssh-443.config`, 61 | Run: func(cmd *cobra.Command, args []string) { 62 | s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) // Build our new spinner 63 | s.Start() 64 | s.Suffix = " Checking vm..." 65 | list, err := provider.List() 66 | if err != nil { 67 | s.Stop() 68 | log.Println(err) 69 | } 70 | 71 | for _, vm := range list.List { 72 | if vm.Name == opt.Vm.Name { 73 | s.Stop() 74 | fmt.Println("\033[31m\u2718\033[0m VM " + opt.Vm.Name + " exists. Aborting...") 75 | os.Exit(1) 76 | } 77 | } 78 | 79 | s.Stop() 80 | fmt.Println("\033[32m\u2714\033[0m Creating VM...") 81 | 82 | // Check Domain Env 83 | if opt.Domain != "" { 84 | s.Start() 85 | s.Suffix = " --domain flag is set... Checking Domain Env..." 86 | err := domain.NewCloudFlareService().CheckEnv() 87 | if err != nil { 88 | s.Stop() 89 | fmt.Println("\033[31m\u2718\033[0m Error on Domain: ", err) 90 | os.Exit(1) 91 | } 92 | } 93 | 94 | applyFileFound := findFile(opt.ApplyFile) 95 | log.Println("[DEBUG] applyFileFound: ", applyFileFound) 96 | opt.Vm.CloudInitFile = findSingleFile(opt.Vm.CloudInitFile) 97 | 98 | // BEGIN SSH Key 99 | publicKeyFile, privateKeyFile := getSSHKeyFilePaths(opt.PublicKeyFile) 100 | log.Println("[DEBUG] publicKeyFile: ", publicKeyFile) 101 | log.Println("[DEBUG] privateKeyFile: ", privateKeyFile) 102 | fmt.Println("\033[32m\u2714\033[0m Using Public Key:", publicKeyFile) 103 | s.Start() 104 | s.Suffix = " Checking SSH Keys..." 105 | opt.Vm.SSHKeyID, err = provider.CreateSSHKey(publicKeyFile) 106 | if err != nil { 107 | s.Stop() 108 | fmt.Println("\033[32m\u2718\033[0m Checking SSH Keys...") 109 | log.Fatalln(err) 110 | } 111 | s.Stop() 112 | fmt.Println("\033[32m\u2714\033[0m Checking SSH Keys... ") 113 | // END SSH Key 114 | 115 | // BEGIN Set VM Name 116 | log.Printf("[DEBUG] keyID: %s", opt.Vm.SSHKeyID) 117 | if opt.Vm.Name == "" { 118 | if viper.GetString("vm.name") != "" { 119 | opt.Vm.Name = viper.GetString("vm.name") 120 | } else { 121 | opt.Vm.Name = tools.GenerateMachineUniqueName() 122 | } 123 | } 124 | s.Restart() 125 | s.Suffix = " VM Starting..." 126 | // END Set VM Name 127 | 128 | vm, err := provider.Deploy(opt.Vm) 129 | if err != nil { 130 | log.Println(err) 131 | } 132 | s.Restart() 133 | s.Suffix = " VM IP: " + vm.IP 134 | s.Stop() 135 | fmt.Println("\033[32m\u2714\033[0m" + s.Suffix) 136 | 137 | log.Println("[DEBUG] Vm:" + vm.String()) 138 | privateKey, err := os.ReadFile(privateKeyFile) 139 | if err != nil { 140 | log.Println(err) 141 | } 142 | 143 | // BEGIN Cloud-init 144 | log.Println("[DEBUG] waiting for cloud-init") 145 | log.Println("[DEBUG] ssh port: ", opt.Vm.SSHPort) 146 | s.Stop() 147 | // fmt.Println("\033[32m\u2714\033[0m VM Starting...") 148 | remote := tools.Remote{ 149 | Username: viper.GetString(cloudProvider + ".vm.username"), 150 | IPAddress: vm.IP, 151 | SSHPort: opt.Vm.SSHPort, 152 | PrivateKey: string(privateKey), 153 | Spinner: s, 154 | } 155 | 156 | // BEGIN Domain 157 | if opt.Domain != "" { 158 | s.Restart() 159 | s.Suffix = " Requesting Domain..." 160 | _, err := domain.NewCloudFlareService().SetRecord(&domain.SetRecordRequest{ 161 | Subdomain: opt.Domain, 162 | Ipaddress: vm.IP, 163 | }) 164 | s.Stop() 165 | if err != nil { 166 | fmt.Println("\033[31m\u2718\033[0m Error on Domain: ") 167 | log.Println(err) 168 | } else { 169 | fmt.Println("\033[32m\u2714\033[0m Domain is ready: ") 170 | } 171 | } 172 | 173 | s.Restart() 174 | s.Suffix = " Waiting for VM to be ready..." 175 | remote.WaitForCloudInit(viper.GetString("vm.cloud-init.timeout")) 176 | s.Stop() 177 | fmt.Println("\033[32m\u2714\033[0m VM is Ready") 178 | log.Println("[DEBUG] cloud-init finished") 179 | // END Cloud-init 180 | 181 | s.Restart() 182 | s.Suffix = " Configuring VM..." 183 | if opt.DotEnvFile != "" { 184 | dotEnvVars, err := tools.ParseDotEnvFile(opt.DotEnvFile) 185 | if err != nil { 186 | log.Println(err) 187 | } 188 | opt.Variables = append(dotEnvVars, opt.Variables...) 189 | } 190 | 191 | // Upload Files 192 | if len(uploadSlice) > 0 { 193 | ProcessUploadSlice(uploadSlice, remote) 194 | } 195 | 196 | // BEGIN Apply File 197 | for i, applyFile := range applyFileFound { 198 | s.Restart() 199 | s.Suffix = " Running " + opt.ApplyFile[i] + " on Remote..." 200 | 201 | err = remote.CopyAndRunRemoteFile(&tools.CopyAndRunRemoteFileConfig{ 202 | File: applyFile, 203 | Vars: opt.Variables, 204 | }) 205 | if err != nil { 206 | log.Println(err) 207 | } 208 | s.Stop() 209 | fmt.Println("\033[32m\u2714\033[0m " + opt.ApplyFile[i] + " ran on Remote") 210 | 211 | } 212 | if len(downloadSlice) > 0 { 213 | ProcessDownloadSlice(downloadSlice, remote) 214 | } 215 | s.Stop() 216 | fmt.Println("\033[32m\u2714\033[0m VM Configured...") 217 | }, 218 | } 219 | -------------------------------------------------------------------------------- /cmd/destroy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "github.com/briandowns/spinner" 11 | "github.com/cdalar/onctl/internal/cloud" 12 | 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | force bool 18 | ) 19 | 20 | func init() { 21 | destroyCmd.Flags().BoolVarP(&force, "force", "f", false, "force destroy VM(s) without confirmation") 22 | } 23 | 24 | var destroyCmd = &cobra.Command{ 25 | Use: "destroy", 26 | Aliases: []string{"down", "delete", "remove", "rm"}, 27 | Short: "Destroy VM(s)", 28 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 29 | VMList, err := provider.List() 30 | list := []string{} 31 | for _, vm := range VMList.List { 32 | list = append(list, vm.Name) 33 | } 34 | 35 | if err != nil { 36 | return nil, cobra.ShellCompDirectiveError 37 | } 38 | 39 | return list, cobra.ShellCompDirectiveNoFileComp 40 | }, 41 | Run: func(cmd *cobra.Command, args []string) { 42 | s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) // Build our new spinner 43 | log.Println("[DEBUG] args: ", args) 44 | if len(args) == 0 { 45 | fmt.Println("Please provide a VM id or 'all' to destroy all VMs") 46 | return 47 | } 48 | 49 | switch args[0] { 50 | case "all": 51 | // Tear down all servers 52 | if !force { 53 | if !yesNo() { 54 | os.Exit(0) 55 | } 56 | } 57 | log.Println("[DEBUG] Tear down all servers") 58 | servers, err := provider.List() 59 | if err != nil { 60 | log.Println(err) 61 | } 62 | log.Println("[DEBUG] Servers: ", servers.List) 63 | var wg sync.WaitGroup 64 | for _, server := range servers.List { 65 | wg.Add(1) 66 | go func(server cloud.Vm) { 67 | defer wg.Done() 68 | s.Start() 69 | s.Suffix = " Destroying VM..." 70 | if err := provider.Destroy(server); err != nil { 71 | fmt.Println("\033[31m\u2718\033[0m Could not destroy VM: " + server.Name) 72 | log.Println(err) 73 | } 74 | s.Stop() 75 | fmt.Println("\033[32m\u2714\033[0m VM Destroyed: " + server.Name) 76 | }(server) 77 | } 78 | wg.Wait() 79 | fmt.Println("\033[32m\u2714\033[0m ALL VM(s) are destroyed") 80 | default: 81 | // Tear down specific server 82 | serverName := args[0] 83 | log.Println("[DEBUG] Tear down server: " + serverName) 84 | s.Start() 85 | s.Suffix = " Destroying VM..." 86 | if err := provider.Destroy(cloud.Vm{Name: serverName}); err != nil { 87 | s.Stop() 88 | fmt.Println("\033[31m\u2718\033[0m Cannot destroy VM: " + serverName) 89 | fmt.Println(err) 90 | os.Exit(1) 91 | } 92 | s.Stop() 93 | fmt.Println("\033[32m\u2714\033[0m VM Destroyed: " + serverName) 94 | } 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/cdalar/onctl/internal/files" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | const ( 14 | onctlDirName = ".onctl" 15 | initDir = "init" 16 | ) 17 | 18 | var initCmd = &cobra.Command{ 19 | Use: "init", 20 | Short: "init onctl environment", 21 | Run: func(cmd *cobra.Command, args []string) { 22 | if err := initializeOnctlEnv(); err != nil { 23 | log.Fatal(err) 24 | } 25 | }, 26 | } 27 | 28 | func initializeOnctlEnv() error { 29 | // Determine the target .onctl directory 30 | localDir, err := os.Getwd() 31 | if err != nil { 32 | return fmt.Errorf("failed to get working directory: %v", err) 33 | } 34 | localOnctlPath := filepath.Join(localDir, onctlDirName) 35 | 36 | homeDir, err := os.UserHomeDir() 37 | if err != nil { 38 | return fmt.Errorf("failed to get home directory: %v", err) 39 | } 40 | homeOnctlPath := filepath.Join(homeDir, onctlDirName) 41 | 42 | var targetPath string 43 | if _, err := os.Stat(localOnctlPath); os.IsNotExist(err) { 44 | // If .onctl doesn't exist in current directory, use home directory 45 | targetPath = homeOnctlPath 46 | } else { 47 | targetPath = localOnctlPath 48 | } 49 | 50 | // Create the .onctl directory if it doesn't exist 51 | if _, err := os.Stat(targetPath); os.IsNotExist(err) { 52 | if err := os.Mkdir(targetPath, os.ModePerm); err != nil { 53 | return fmt.Errorf("failed to create %s directory: %w", targetPath, err) 54 | } 55 | return populateOnctlEnv(targetPath) 56 | } 57 | 58 | fmt.Printf("onctl environment already initialized in %s\n", targetPath) 59 | return nil 60 | } 61 | 62 | func populateOnctlEnv(targetPath string) error { 63 | embedDir, err := files.EmbededFiles.ReadDir(initDir) 64 | if err != nil { 65 | return fmt.Errorf("failed to read embedded files: %w", err) 66 | } 67 | 68 | for _, configFile := range embedDir { 69 | log.Println("[DEBUG] initFile:", configFile.Name()) 70 | eFile, err := files.EmbededFiles.ReadFile(filepath.Join(initDir, configFile.Name())) 71 | if err != nil { 72 | return fmt.Errorf("failed to read file %s: %w", configFile.Name(), err) 73 | } 74 | 75 | targetFilePath := filepath.Join(targetPath, configFile.Name()) 76 | if err := os.WriteFile(targetFilePath, eFile, 0644); err != nil { 77 | return fmt.Errorf("failed to write file %s: %w", configFile.Name(), err) 78 | } 79 | } 80 | fmt.Printf("onctl environment initialized in %s\n", targetPath) 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "gopkg.in/yaml.v3" 10 | 11 | "github.com/cdalar/onctl/internal/cloud" 12 | "github.com/cdalar/onctl/internal/tools/puppet" 13 | 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | var output string 19 | 20 | func init() { 21 | listCmd.Flags().StringVarP(&output, "output", "o", "tab", "output format (tab, json, yaml, puppet, ansiable)") 22 | } 23 | 24 | var listCmd = &cobra.Command{ 25 | Use: "ls", 26 | Aliases: []string{"list"}, 27 | Short: "List VMs", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | 30 | var ( 31 | tmpl string 32 | serverList cloud.VmList 33 | err error 34 | ) 35 | serverList, err = provider.List() 36 | if err != nil { 37 | log.Println(err) 38 | } 39 | log.Println("[DEBUG] VM List: ", serverList) 40 | 41 | switch output { 42 | case "puppet": 43 | var puppetInventory puppet.Inventory 44 | puppetInventory.Groups = make([]puppet.Group, 1) 45 | puppetInventory.Groups[0].Name = "servers" 46 | puppetInventory.Config.SSH = puppet.SSH{ 47 | User: "root", 48 | HostKeyCheck: false, 49 | NativeSSH: true, 50 | SSHCommand: "ssh", 51 | } 52 | puppetInventory.Config.Transport = "ssh" 53 | _, privateKey := getSSHKeyFilePaths("") 54 | puppetInventory.Config.SSH.PrivateKey = privateKey 55 | for _, server := range serverList.List { 56 | puppetInventory.Groups[0].Targets = append(puppetInventory.Groups[0].Targets, server.IP) 57 | } 58 | 59 | encoder := yaml.NewEncoder(os.Stdout) 60 | encoder.SetIndent(2) // Set YAML indentation 61 | err = encoder.Encode(puppetInventory) 62 | if err != nil { 63 | log.Println(err) 64 | } 65 | case "ansible": 66 | log.Println("[DEBUG] Ansible output") 67 | username := viper.GetString(cloudProvider + ".vm.username") 68 | if err != nil { 69 | log.Println(err) 70 | } 71 | fmt.Println("[onctl]") 72 | for _, server := range serverList.List { 73 | fmt.Println(server.IP, "ansible_user="+username) 74 | } 75 | case "json": 76 | jsonList, err := json.Marshal(serverList.List) 77 | if err != nil { 78 | log.Println(err) 79 | } 80 | fmt.Println(string(jsonList)) 81 | case "yaml": 82 | yamlList, err := yaml.Marshal(serverList.List) 83 | if err != nil { 84 | log.Println(err) 85 | } 86 | fmt.Println(string(yamlList)) 87 | default: 88 | switch cloudProvider { 89 | case "hetzner": 90 | tmpl = "CLOUD\tID\tNAME\tLOCATION\tTYPE\tPUBLIC IP\tPRIVATE IP\tSTATE\tAGE\tCOST/H\tUSAGE\n{{range .List}}{{.Provider}}\t{{.ID}}\t{{.Name}}\t{{.Location}}\t{{.Type}}\t{{.IP}}\t{{.PrivateIP}}\t{{.Status}}\t{{durationFromCreatedAt .CreatedAt}}\t{{.Cost.CostPerHour}}{{.Cost.Currency}}\t{{.Cost.AccumulatedCost}}{{.Cost.Currency}}\n{{end}}" 91 | default: 92 | tmpl = "CLOUD\tID\tNAME\tLOCATION\tTYPE\tPUBLIC IP\tPRIVATE IP\tSTATE\tAGE\n{{range .List}}{{.Provider}}\t{{.ID}}\t{{.Name}}\t{{.Location}}\t{{.Type}}\t{{.IP}}\t{{.PrivateIP}}\t{{.Status}}\t{{durationFromCreatedAt .CreatedAt}}\n{{end}}" 93 | } 94 | TabWriter(serverList, tmpl) 95 | } 96 | 97 | }, 98 | } 99 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/cdalar/onctl/internal/cloud" 10 | "github.com/cdalar/onctl/internal/provideraws" 11 | "github.com/cdalar/onctl/internal/providerazure" 12 | "github.com/cdalar/onctl/internal/providergcp" 13 | "github.com/cdalar/onctl/internal/providerhtz" 14 | "github.com/cdalar/onctl/internal/tools" 15 | 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var ( 20 | rootCmd = &cobra.Command{ 21 | Use: "onctl", 22 | Short: "onctl is a tool to manage cross platform resources in cloud", 23 | Long: `onctl is a tool to manage cross platform resources in cloud`, 24 | Example: ` # List all VMs 25 | onctl ls 26 | 27 | # Create a VM with docker installed 28 | onctl create -n test -a docker/docker.sh 29 | 30 | # SSH into a VM 31 | onctl ssh test 32 | 33 | # Destroy a VM 34 | onctl destroy test`, 35 | } 36 | cloudProvider string 37 | cloudProviderList = []string{"aws", "hetzner", "azure", "gcp"} 38 | provider cloud.CloudProviderInterface 39 | ) 40 | 41 | func checkCloudProvider() string { 42 | cloudProvider = os.Getenv("ONCTL_CLOUD") 43 | // ONCTL_CLOUD is set 44 | if cloudProvider != "" { 45 | if !tools.Contains(cloudProviderList, cloudProvider) { 46 | log.Println("Cloud Platform (" + cloudProvider + ") is not Supported\nPlease use one of the following: " + strings.Join(cloudProviderList, ",")) 47 | os.Exit(1) 48 | } 49 | } else { // ONCTL_CLOUD is not set 50 | cloudProvider = tools.WhichCloudProvider() 51 | if cloudProvider != "none" { 52 | err := os.Setenv("ONCTL_CLOUD", cloudProvider) 53 | if err != nil { 54 | log.Println(err) 55 | } 56 | return cloudProvider 57 | } else { 58 | fmt.Println("No Cloud Provider Set.\nPlease set the ONCTL_CLOUD environment variable to one of the following: " + strings.Join(cloudProviderList, ",")) 59 | os.Exit(1) 60 | } 61 | } 62 | return cloudProvider 63 | } 64 | 65 | // Execute executes the root command. 66 | func Execute() error { 67 | log.Println("[DEBUG] Args: " + strings.Join(os.Args, ",")) 68 | if len(os.Args) > 1 && os.Args[1] != "init" && os.Args[1] != "version" { 69 | cloudProvider = checkCloudProvider() 70 | log.Println("[DEBUG] Cloud: " + cloudProvider) 71 | err := ReadConfig(cloudProvider) 72 | if err != nil { 73 | log.Fatalln(err) 74 | } 75 | } 76 | switch cloudProvider { 77 | case "hetzner": 78 | provider = &cloud.ProviderHetzner{ 79 | Client: providerhtz.GetClient(), 80 | } 81 | case "gcp": 82 | provider = &cloud.ProviderGcp{ 83 | Client: providergcp.GetClient(), 84 | GroupClient: providergcp.GetGroupClient(), 85 | } 86 | 87 | case "aws": 88 | provider = &cloud.ProviderAws{ 89 | Client: provideraws.GetClient(), 90 | } 91 | case "azure": 92 | provider = &cloud.ProviderAzure{ 93 | ResourceGraphClient: providerazure.GetResourceGraphClient(), 94 | VmClient: providerazure.GetVmClient(), 95 | NicClient: providerazure.GetNicClient(), 96 | PublicIPClient: providerazure.GetIPClient(), 97 | SSHKeyClient: providerazure.GetSSHKeyClient(), 98 | VnetClient: providerazure.GetVnetClient(), 99 | } 100 | } 101 | return rootCmd.Execute() 102 | } 103 | 104 | func init() { 105 | rootCmd.AddCommand(versionCmd) 106 | rootCmd.AddCommand(listCmd) 107 | rootCmd.AddCommand(createCmd) 108 | rootCmd.AddCommand(destroyCmd) 109 | rootCmd.AddCommand(sshCmd) 110 | rootCmd.AddCommand(initCmd) 111 | } 112 | -------------------------------------------------------------------------------- /cmd/ssh.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/briandowns/spinner" 10 | "github.com/cdalar/onctl/internal/tools" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var ( 16 | port int 17 | apply []string 18 | downloadSlice []string 19 | uploadSlice []string 20 | key string 21 | ) 22 | 23 | func init() { 24 | sshCmd.Flags().StringVarP(&key, "key", "k", "", "Path to privateKey file (default: ~/.ssh/id_rsa))") 25 | sshCmd.Flags().IntVarP(&port, "port", "p", 22, "ssh port") 26 | sshCmd.Flags().StringSliceVarP(&apply, "apply-file", "a", []string{}, "bash script file(s) to run on remote") 27 | sshCmd.Flags().StringSliceVarP(&downloadSlice, "download", "d", []string{}, "List of files to download") 28 | sshCmd.Flags().StringSliceVarP(&uploadSlice, "upload", "u", []string{}, "List of files to upload") 29 | sshCmd.Flags().StringVar(&opt.DotEnvFile, "dot-env", "", "dot-env (.env) file") 30 | sshCmd.Flags().StringSliceVarP(&opt.Variables, "vars", "e", []string{}, "Environment variables passed to the script") 31 | } 32 | 33 | var sshCmd = &cobra.Command{ 34 | Use: "ssh VM_NAME", 35 | Short: "Spawn an SSH connection to a VM", 36 | Args: cobra.MinimumNArgs(1), 37 | TraverseChildren: true, 38 | DisableFlagsInUseLine: true, 39 | ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 40 | VMList, err := provider.List() 41 | list := []string{} 42 | for _, vm := range VMList.List { 43 | list = append(list, vm.Name) 44 | } 45 | 46 | if err != nil { 47 | return nil, cobra.ShellCompDirectiveError 48 | } 49 | 50 | return list, cobra.ShellCompDirectiveNoFileComp 51 | }, 52 | 53 | Run: func(cmd *cobra.Command, args []string) { 54 | s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) // Build our new spinner 55 | applyFileFound := findFile(apply) 56 | log.Println("[DEBUG] args: ", args) 57 | 58 | if len(args) == 0 { 59 | fmt.Println("Please provide a VM id") 60 | return 61 | } 62 | log.Println("[DEBUG] port:", port) 63 | log.Println("[DEBUG] filename:", applyFileFound) 64 | log.Println("[DEBUG] key:", key) 65 | _, privateKeyFile := getSSHKeyFilePaths(key) 66 | log.Println("[DEBUG] privateKeyFile:", privateKeyFile) 67 | 68 | privateKey, err := os.ReadFile(privateKeyFile) 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | vm, err := provider.GetByName(args[0]) 73 | if err != nil { 74 | log.Fatalln(err) 75 | } 76 | remote := tools.Remote{ 77 | Username: viper.GetString(cloudProvider + ".vm.username"), 78 | IPAddress: vm.IP, 79 | SSHPort: port, 80 | PrivateKey: string(privateKey), 81 | Spinner: s, 82 | } 83 | 84 | if opt.DotEnvFile != "" { 85 | dotEnvVars, err := tools.ParseDotEnvFile(opt.DotEnvFile) 86 | if err != nil { 87 | log.Println(err) 88 | } 89 | opt.Variables = append(dotEnvVars, opt.Variables...) 90 | } 91 | 92 | if len(uploadSlice) > 0 { 93 | ProcessUploadSlice(uploadSlice, remote) 94 | } 95 | 96 | // BEGIN Apply File 97 | for i, applyFile := range applyFileFound { 98 | s.Restart() 99 | s.Suffix = " Running " + apply[i] + " on Remote..." 100 | 101 | err = remote.CopyAndRunRemoteFile(&tools.CopyAndRunRemoteFileConfig{ 102 | File: applyFile, 103 | Vars: opt.Variables, 104 | }) 105 | if err != nil { 106 | log.Println(err) 107 | } 108 | s.Stop() 109 | fmt.Println("\033[32m\u2714\033[0m " + apply[i] + " ran on Remote") 110 | 111 | } 112 | // END Apply File 113 | 114 | if len(downloadSlice) > 0 { 115 | ProcessDownloadSlice(downloadSlice, remote) 116 | } 117 | if len(applyFileFound) == 0 && len(downloadSlice) == 0 && len(uploadSlice) == 0 { 118 | provider.SSHInto(args[0], port, privateKeyFile) 119 | } 120 | }, 121 | } 122 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // Version of CLI set by go build command 10 | // go build -ldflags "-X main.Version=`git rev-parse HEAD`" 11 | var Version = "Not Set" 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Print the version number of onctl", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("Version: " + Version) 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/docs/deployments.md: -------------------------------------------------------------------------------- 1 | # Deployments 2 | 3 | The main component to deploy your app with ease. 4 | 5 | ```bash 6 | Usage: 7 | onctl deploy [flags] 8 | 9 | Flags: 10 | --cnames strings CNAMES link to this app 11 | -c, --cpu string CPU (m) limit of the app. (default "250") 12 | --env string Name of environment variable group 13 | -h, --help help for deploy 14 | -i, --image string ImageName and Tag ex. nginx:latest 15 | -m, --memory string Memory (Mi) limit of the app. (default "250") 16 | --name string Name of the app. 17 | -p, --port int32 Port of the app. (default 80) 18 | --public makes deployment public and accessible from a onkube.app subdomain 19 | -v, --volume string Volume : to mount. 20 | 21 | ``` 22 | 23 | :::warning Public Deployments 24 | Deployment are by default **not** exposed to internet. In order to get a public URL 25 | You should use --public option 26 | ::: 27 | 28 | ## Image 29 | 30 | The url of the image to deploy. ex. `alpine:latest` / `nginx:alpine` etc. 31 | 32 | :::note To Deploy an image from a private repository 33 | You should add your access credentials first 34 | 35 | * `onctl reg add -u -p ` - Add your container registry credentials 36 | ::: 37 | 38 | ## Environment Variables 39 | 40 | 1. Define your Environment Variables. 41 | 2. Pass environment variable group name to deploy command 42 | ```bash 43 | onctl deploy -i nginx:alpine --env 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## initialize 4 | 1. `onctl init` on your project folder. This will create a `.onctl` directory and create files related to each cloud configuration. The directory will look like this. 5 | ``` 6 | ❯ tree 7 | . 8 | ├── aws.yaml 9 | ├── azure.yaml 10 | ├── hetzner.yaml 11 | ├── .yaml 12 | └── onctl.yaml 13 | 14 | 1 directory, 3 files 15 | ``` 16 | 1. `onctl.yaml` file is the main configuration file and holds the all non-cloud specific parameters. 17 | 2. each provider has it's own configuration yaml file to define things specific things like (*azure resourceGroups*) 18 | 3. change each configuration file depending on your needs. 19 | 20 | ## set cloud provider 21 | 1. set `ONCTL_CLOUD` environment variables to the name of the cloud provider. Supported values; 22 | - azure 23 | - hetzner 24 | - aws 25 | 1. 26 | ``` 27 | export ONCTL_CLOUD=hetzner 28 | ``` 29 | 30 | !!! note 31 | 32 | If you don't set ONCTL_CLOUD environment variable, onctl tool will try to find credentials on your shell and use the first one it finds. 33 | 34 | ## spin up a virtual machine 35 | 1. We're ready. Let's create a Virtual Machine (Instance) 36 | ``` 37 | ❯ onctl up -n onctl-test 38 | Using: hetzner 39 | Creating SSHKey: onctl-xxx... 40 | SSH Key already exists (onctl-xxx) 41 | Starting server... 42 | Server IP: x.x.x.x 43 | Vm started. 44 | ``` 45 | ## ssh access 46 | 1. Just use ssh command to ssh into the virtual machine. 47 | ``` 48 | ❯ onctl ssh onctl-test 49 | Using: hetzner 50 | . 51 | . 52 | . 53 | root@onctl-test:~# 54 | ``` -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | # Onctl 5 | 6 | `onctl` is a tool to manage virtual machines in multi-cloud. 7 | 8 | Check 🌍 https://docs.onctl.io for detailed documentation 9 | 10 | [![build](https://github.com/cdalar/onctl/actions/workflows/build.yml/badge.svg)](https://github.com/cdalar/onctl/actions/workflows/build.yml) 11 | [![Go Report Card](https://goreportcard.com/badge/github.com/cdalar/onctl)](https://goreportcard.com/report/github.com/cdalar/onctl) 12 | [![CodeQL](https://github.com/cdalar/onctl/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/cdalar/onctl/actions/workflows/github-code-scanning/codeql) 13 | [![codecov](https://codecov.io/gh/cdalar/onctl/graph/badge.svg?token=7VU7H1II09)](https://codecov.io/gh/cdalar/onctl) 14 | ![Github All Releases](https://img.shields.io/github/downloads/cdalar/onctl/total.svg) 15 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/cdalar/onctl?sort=semver) 16 | 17 | 18 | ## What onctl brings 19 | 20 | - 🌍 Simple intuitive CLI to run VMs in seconds. 21 | - ⛅️ Supports multi cloud providers (aws, azure, hetzner, more coming soon...) 22 | - 🚀 Sets your public key and Gives you SSH access with `onctl ssh ` 23 | - ✨ Cloud-init support. Set your own cloud-init file `onctl up -n qwe --cloud-init ` 24 | - 🤖 Use ready to use templates to configure your vm. Check [onctl-templates](https://github.com/cdalar/onctl-templates) `onctl up -n qwe -a k3s/k3s-server.sh` 25 | - 🗂️ Use your custom local or http accessible scripts to configure your vm. `onctl ssh qwe -a ` 26 | 27 | ## Quick Start 28 | 29 | initialize project. this will create a `.onctl` directory. check configuration file and set as needed. 30 | ```bash 31 | ❯ onctl init 32 | onctl environment initialized 33 | ``` 34 | 35 | ### Mac OS 36 | 37 | ```zsh 38 | brew install cdalar/tap/onctl 39 | ``` 40 | 41 | ### Linux 42 | 43 | ```bash 44 | curl -sLS https://www.onctl.com/get.sh | bash 45 | sudo install onctl /usr/local/bin/ 46 | ``` 47 | 48 | ### Windows 49 | 50 | - download windows binary from [releases page](https://github.com/cdalar/onctl/releases) 51 | - unzip and copy onctl.exe to a location in PATH 52 | 53 | # Enjoy ✅ 54 | 55 | ```bash 56 | ❯ onctl 57 | onctl is a tool to manage cross platform resources in cloud 58 | 59 | Usage: 60 | onctl [command] 61 | 62 | Available Commands: 63 | completion Generate the autocompletion script for the specified shell 64 | create Create a VM 65 | destroy Destroy VM(s) 66 | help Help about any command 67 | init init onctl environment 68 | ls List VMs 69 | ssh Spawn an SSH connection to a VM 70 | version Print the version number of onctl 71 | 72 | Flags: 73 | -h, --help help for onctl 74 | 75 | Use "onctl [command] --help" for more information about a command. 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/docs/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/docs/templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | ## initialize virtual machines 4 | 5 | - use --apply-file (-a in short) to execute an initialization script 6 | - use --cloud-init (-i in short) to set cloud-init script on virtual machines startup. 7 | 8 | ## bash 9 | 10 | 1. use your own script. 11 | 12 | ```bash 13 | onctl up -a scripts/init.sh 14 | ``` 15 | to use the file `scripts/init.sh` in your current directory. 16 | 17 | 1. use onctl-templates repo. 18 | 19 | files on the `onctl-templates` repo can be access directly by using the relative path. 20 | 21 | ```bash 22 | onctl up -a wireguard/vpn.sh # https://templates.onctl.com/wireguard/vpn.sh 23 | ``` 24 | 25 | 1. use any external source as a HTTP URL. 26 | 27 | any file that is accessiable via URL can be used. 28 | 29 | ```bash 30 | onctl up -a https://gist.githubusercontent.com/cdalar/dabdc001059089f553879a7b535e9b21/raw/02f336857b04eb13bc7ceeec1e66395bd615824b/helloworld.sh 31 | ``` 32 | to use the embeded file. Embeded files can be found under `internal/files/` in repository. 33 | 34 | ## cloud-init 35 | 36 | check: [cloud-init docs](https://cloudinit.readthedocs.io/en/latest/) 37 | 38 | To set a cloud-init configuration to your virtual machine. Just add `--cloud-init` flag to your command. 39 | 40 | ex. this command will set the ssh port to 443. 41 | ```bash 42 | onctl up -a wireguard/vpn.sh --cloud-init cloud-init-ssh-443.config 43 | ``` 44 | 45 | ## precedence on scripts 46 | 1. local file 47 | 1. embeded files 48 | 1. files on [onctl-templates](https://github.com/cdalar/onctl-templates) repo 49 | 1. as defined on URL (https://example.com) 50 | -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from 'prism-react-renderer'; 2 | import type { Config } from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | 5 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 6 | 7 | const config: Config = { 8 | title: 'Deploy with Ease', 9 | tagline: 'multi-cloud deployment made easy', 10 | favicon: 'img/favicon.ico', 11 | 12 | // Set the production url of your site here 13 | url: 'https://docs.onctl.io', 14 | // Set the // pathname under which your site is served 15 | // For GitHub pages deployment, it is often '//' 16 | baseUrl: '/', 17 | 18 | // GitHub pages deployment config. 19 | // If you aren't using GitHub pages, you don't need these. 20 | organizationName: 'cdalar', // Usually your GitHub org/user name. 21 | projectName: 'onctl', // Usually your repo name. 22 | 23 | onBrokenLinks: 'throw', 24 | onBrokenMarkdownLinks: 'warn', 25 | 26 | // Even if you don't use internationalization, you can use this field to set 27 | // useful metadata like html lang. For example, if your site is Chinese, you 28 | // may want to replace "en" with "zh-Hans". 29 | i18n: { 30 | defaultLocale: 'en', 31 | locales: ['en'], 32 | }, 33 | 34 | presets: [ 35 | [ 36 | 'classic', 37 | { 38 | docs: { 39 | sidebarPath: './sidebars.ts', 40 | // Please change this to your repo. 41 | // Remove this to remove the "edit this page" links. 42 | editUrl: 43 | 'https://github.com/cdalar/onctl/tree/main/docs/', 44 | }, 45 | // blog: { 46 | // showReadingTime: true, 47 | // feedOptions: { 48 | // type: ['rss', 'atom'], 49 | // xslt: true, 50 | // }, 51 | // // Please change this to your repo. 52 | // // Remove this to remove the "edit this page" links. 53 | // editUrl: 54 | // 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', 55 | // // Useful options to enforce blogging best practices 56 | // onInlineTags: 'warn', 57 | // onInlineAuthors: 'warn', 58 | // onUntruncatedBlogPosts: 'warn', 59 | // }, 60 | blog: false, 61 | theme: { 62 | customCss: './src/css/custom.css', 63 | }, 64 | } satisfies Preset.Options, 65 | ], 66 | ], 67 | 68 | plugins: [ 69 | [ 70 | require.resolve('docusaurus-lunr-search'), 71 | { 72 | // Options for the plugin (optional) 73 | languages: ['en'], // Specify languages here if needed 74 | }, 75 | ], 76 | ], 77 | 78 | 79 | themeConfig: { 80 | // Replace with your project's social card 81 | image: 'img/docusaurus-social-card.jpg', 82 | navbar: { 83 | title: 'onctl', 84 | logo: { 85 | alt: 'onkube Logo', 86 | src: 'img/onkube.svg', 87 | }, 88 | items: [ 89 | { 90 | type: 'docSidebar', 91 | sidebarId: 'tutorialSidebar', 92 | position: 'left', 93 | label: 'Docs', 94 | }, 95 | // {to: '/blog', label: 'Blog', position: 'left'}, 96 | { 97 | href: 'https://github.com/cdalar/onctl', 98 | label: 'GitHub', 99 | position: 'right', 100 | }, 101 | ], 102 | }, 103 | footer: { 104 | style: 'dark', 105 | // links: [ 106 | // { 107 | // title: 'Docs', 108 | // items: [ 109 | // { 110 | // label: 'Tutorial', 111 | // to: '/docs', 112 | // }, 113 | // ], 114 | // }, 115 | // { 116 | // title: 'Community', 117 | // items: [ 118 | // { 119 | // label: 'Stack Overflow', 120 | // href: 'https://stackoverflow.com/questions/tagged/docusaurus', 121 | // }, 122 | // { 123 | // label: 'Discord', 124 | // href: 'https://discordapp.com/invite/docusaurus', 125 | // }, 126 | // { 127 | // label: 'X', 128 | // href: 'https://x.com/docusaurus', 129 | // }, 130 | // ], 131 | // }, 132 | // { 133 | // title: 'More', 134 | // items: [ 135 | // { 136 | // label: 'Blog', 137 | // to: '/blog', 138 | // }, 139 | // { 140 | // label: 'GitHub', 141 | // href: 'https://github.com/facebook/docusaurus', 142 | // }, 143 | // ], 144 | // }, 145 | // ], 146 | copyright: `Copyright © ${new Date().getFullYear()} onctl`, 147 | }, 148 | prism: { 149 | theme: prismThemes.github, 150 | darkTheme: prismThemes.dracula, 151 | }, 152 | } satisfies Preset.ThemeConfig, 153 | }; 154 | 155 | export default config; 156 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.7.0", 19 | "@docusaurus/preset-classic": "3.7.0", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^2.0.0", 22 | "docusaurus-lunr-search": "^3.6.0", 23 | "prism-react-renderer": "^2.3.0", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0", 26 | "rehype-katex": "^6.0.3", 27 | "remark-math": "^5.1.1" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "3.7.0", 31 | "@docusaurus/tsconfig": "3.7.0", 32 | "@docusaurus/types": "3.7.0", 33 | "typescript": "~5.6.2" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.5%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 3 chrome version", 43 | "last 3 firefox version", 44 | "last 5 safari version" 45 | ] 46 | }, 47 | "engines": { 48 | "node": ">=18.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 4 | 5 | /** 6 | * Creating a sidebar enables you to: 7 | - create an ordered group of docs 8 | - render a sidebar for each doc of that group 9 | - provide next/previous navigation 10 | 11 | The sidebars can be generated from the filesystem, or explicitly defined here. 12 | 13 | Create as many sidebars as you want. 14 | */ 15 | const sidebars: SidebarsConfig = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | export default sidebars; 34 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import type {ReactNode} from 'react'; 2 | import clsx from 'clsx'; 3 | import Heading from '@theme/Heading'; 4 | import styles from './styles.module.css'; 5 | 6 | type FeatureItem = { 7 | title: string; 8 | Svg: React.ComponentType>; 9 | description: ReactNode; 10 | }; 11 | 12 | const FeatureList: FeatureItem[] = [ 13 | { 14 | title: 'Easy to Use', 15 | Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, 16 | description: ( 17 | <> 18 | Docusaurus was designed from the ground up to be easily installed and 19 | used to get your website up and running quickly. 20 | 21 | ), 22 | }, 23 | { 24 | title: 'Focus on What Matters', 25 | Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, 26 | description: ( 27 | <> 28 | Docusaurus lets you focus on your docs, and we'll do the chores. Go 29 | ahead and move your docs into the docs directory. 30 | 31 | ), 32 | }, 33 | { 34 | title: 'Powered by React', 35 | Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, 36 | description: ( 37 | <> 38 | Extend or customize your website layout by reusing React. Docusaurus can 39 | be extended while reusing the same header and footer. 40 | 41 | ), 42 | }, 43 | ]; 44 | 45 | function Feature({title, Svg, description}: FeatureItem) { 46 | return ( 47 |
48 |
49 | 50 |
51 |
52 | {title} 53 |

{description}

54 |
55 |
56 | ); 57 | } 58 | 59 | export default function HomepageFeatures(): ReactNode { 60 | return ( 61 |
62 |
63 |
64 | {FeatureList.map((props, idx) => ( 65 | 66 | ))} 67 |
68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/pages/get.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Repository details 4 | REPO="cdalar/onctl" 5 | GITHUB="https://github.com" 6 | 7 | # Get the latest release tag from GitHub API 8 | latest_release=$(curl -s "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') 9 | 10 | # Check if we got a valid release tag 11 | if [ -z "$latest_release" ]; then 12 | echo "Error: Could not get latest release tag. Exiting." 13 | exit 1 14 | fi 15 | 16 | echo "Latest release tag is: $latest_release" 17 | 18 | # Determine system architecture 19 | architecture=$(uname -m) 20 | case $architecture in 21 | x86_64) 22 | arch="amd64" 23 | ;; 24 | arm64*|aarch64*) 25 | arch="arm64" 26 | ;; 27 | *) 28 | echo "Error: Unsupported architecture: $architecture" 29 | exit 1 30 | ;; 31 | esac 32 | 33 | # Determine operating system 34 | os=$(uname -s) 35 | case $os in 36 | Linux) 37 | os="linux" 38 | extension=".tar.gz" 39 | unzip_command="tar zxvf" 40 | ;; 41 | Darwin) 42 | os="darwin" 43 | extension=".tar.gz" 44 | unzip_command="tar zxvf" 45 | ;; 46 | CYGWIN*|MINGW32*|MSYS*|MINGW*) 47 | os="windows" 48 | extension=".zip" 49 | unzip_command="unzip -o" 50 | ;; 51 | *) 52 | echo "Error: Unsupported operating system: $os" 53 | exit 1 54 | ;; 55 | esac 56 | 57 | echo "Operating system is: $os" 58 | echo "System architecture is: $architecture" 59 | 60 | # Construct download URL 61 | # This URL pattern is an example and needs to match the actual pattern used in the releases 62 | download_url="$GITHUB/$REPO/releases/download/$latest_release/onctl-${os}-${arch}${extension}" 63 | 64 | # Download the binary 65 | echo "Downloading parampiper from $download_url" 66 | curl -L $download_url -o "onctl-${os}-${arch}-${latest_release}${extension}" 67 | 68 | # Unzip the binary if on Windows or use tar command if on Linux 69 | if [ "$os" = "windows" ]; then 70 | echo "Unzipping onctl-${os}-${arch}-${latest_release}${extension}" 71 | $unzip_command "onctl-${os}-${arch}-${latest_release}${extension}" onctl.exe 72 | else 73 | echo "Extracting onctl-${os}-${arch}-${latest_release}${extension}" 74 | $unzip_command "onctl-${os}-${arch}-${latest_release}${extension}" onctl 75 | fi 76 | 77 | echo "Download and unzip complete. onctl binary is in the current directory." 78 | -------------------------------------------------------------------------------- /docs/src/pages/get_beta.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Repository details 4 | REPO="cdalar/onctl" 5 | GITHUB="https://github.com" 6 | 7 | # Construct download URL 8 | # This URL pattern is an example and needs to match the actual pattern used in the releases 9 | download_url="$GITHUB/$REPO/releases/download/v0.1.0/onctl-linux" 10 | 11 | # Download the binary 12 | curl -L $download_url -o "onctl" 13 | chmod +x onctl 14 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | .buttons { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import clsx from 'clsx'; 3 | import Link from '@docusaurus/Link'; 4 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 5 | import Layout from '@theme/Layout'; 6 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 7 | import Heading from '@theme/Heading'; 8 | 9 | import styles from './index.module.css'; 10 | 11 | function HomepageHeader() { 12 | const { siteConfig } = useDocusaurusContext(); 13 | return ( 14 |
15 |
16 | 17 | {siteConfig.title} 18 | 19 |

{siteConfig.tagline}

20 |
21 | 24 | Get Started 25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export default function Home(): ReactNode { 33 | const { siteConfig } = useDocusaurusContext(); 34 | return ( 35 | 38 | 39 |
40 | 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /docs/src/pages/style.css: -------------------------------------------------------------------------------- 1 | article a[target^="_blank"]::after 2 | { 3 | content: ""; 4 | width: 11px; 5 | height: 11px; 6 | margin-left: 4px; 7 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E"); 8 | background-position: center; 9 | background-repeat: no-repeat; 10 | background-size: contain; 11 | display: inline-block; 12 | } -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdalar/onctl/1fc78b74b57fb1ccbaf458354a085b34cf25be58/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | docs.onctl.io -------------------------------------------------------------------------------- /docs/static/img/docusaurus-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdalar/onctl/1fc78b74b57fb1ccbaf458354a085b34cf25be58/docs/static/img/docusaurus-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdalar/onctl/1fc78b74b57fb1ccbaf458354a085b34cf25be58/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdalar/onctl/1fc78b74b57fb1ccbaf458354a085b34cf25be58/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/onkube.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_tree.svg: -------------------------------------------------------------------------------- 1 | 2 | Focus on What Matters 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | }, 7 | "exclude": [".docusaurus", "build"] 8 | } 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cdalar/onctl 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | cloud.google.com/go/compute v1.38.0 7 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5 v5.2.0 8 | github.com/aws/aws-sdk-go v1.55.7 9 | github.com/briandowns/spinner v1.23.2 10 | github.com/cloudflare/cloudflare-go v0.115.0 11 | github.com/gofrs/uuid/v5 v5.3.2 12 | github.com/hetznercloud/hcloud-go/v2 v2.21.0 13 | github.com/manifoldco/promptui v0.9.0 14 | github.com/spf13/cobra v1.9.1 15 | golang.org/x/term v0.32.0 16 | google.golang.org/api v0.232.0 17 | gopkg.in/yaml.v3 v3.0.1 18 | k8s.io/apimachinery v0.33.1 19 | ) 20 | 21 | require ( 22 | cloud.google.com/go/auth v0.16.1 // indirect 23 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 24 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 25 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect 26 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect 27 | github.com/beorn7/perks v1.0.1 // indirect 28 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 29 | github.com/chzyer/readline v1.5.1 // indirect 30 | github.com/fatih/color v1.16.0 // indirect 31 | github.com/felixge/httpsnoop v1.0.4 // indirect 32 | github.com/fsnotify/fsnotify v1.7.0 // indirect 33 | github.com/go-logr/logr v1.4.2 // indirect 34 | github.com/go-logr/stdr v1.2.2 // indirect 35 | github.com/goccy/go-json v0.10.5 // indirect 36 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 37 | github.com/google/go-querystring v1.1.0 // indirect 38 | github.com/google/s2a-go v0.1.9 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 41 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 42 | github.com/hashicorp/hcl v1.0.0 // indirect 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 | github.com/klauspost/compress v1.17.11 // indirect 45 | github.com/kr/fs v0.1.0 // indirect 46 | github.com/kylelemons/godebug v1.1.0 // indirect 47 | github.com/magiconair/properties v1.8.7 // indirect 48 | github.com/mattn/go-colorable v0.1.13 // indirect 49 | github.com/mattn/go-isatty v0.0.20 // indirect 50 | github.com/mitchellh/mapstructure v1.5.0 // indirect 51 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 52 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 53 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 54 | github.com/prometheus/client_golang v1.21.1 // indirect 55 | github.com/prometheus/client_model v0.6.1 // indirect 56 | github.com/prometheus/common v0.62.0 // indirect 57 | github.com/prometheus/procfs v0.15.1 // indirect 58 | github.com/sagikazarmark/locafero v0.4.0 // indirect 59 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 60 | github.com/sourcegraph/conc v0.3.0 // indirect 61 | github.com/spf13/afero v1.11.0 // indirect 62 | github.com/spf13/cast v1.6.0 // indirect 63 | github.com/spf13/pflag v1.0.6 // indirect 64 | github.com/subosito/gotenv v1.6.0 // indirect 65 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 66 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 67 | go.opentelemetry.io/otel v1.35.0 // indirect 68 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 69 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 70 | go.uber.org/multierr v1.11.0 // indirect 71 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 72 | golang.org/x/net v0.40.0 // indirect 73 | golang.org/x/oauth2 v0.30.0 // indirect 74 | golang.org/x/sys v0.33.0 // indirect 75 | golang.org/x/time v0.11.0 // indirect 76 | google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect 77 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect 78 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect 79 | google.golang.org/grpc v1.72.0 // indirect 80 | google.golang.org/protobuf v1.36.6 // indirect 81 | gopkg.in/ini.v1 v1.67.0 // indirect 82 | ) 83 | 84 | require ( 85 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 86 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 87 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.7.0 88 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 89 | github.com/hashicorp/logutils v1.0.0 90 | github.com/jmespath/go-jmespath v0.4.0 // indirect 91 | github.com/pkg/sftp v1.13.9 92 | github.com/spf13/viper v1.19.0 93 | golang.org/x/crypto v0.38.0 94 | golang.org/x/text v0.25.0 // indirect 95 | ) 96 | -------------------------------------------------------------------------------- /gon/config-amd64.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": [ 3 | "./dist/onctl_darwin_amd64_v1/onctl" 4 | ], 5 | "bundle_id": "net.dalar.onctl", 6 | "apple_id": { 7 | "username": "cdalar@me.com", 8 | "password": "@env:AC_PASSWORD", 9 | "provider": "CemalDalar182157262" 10 | }, 11 | "sign": { 12 | "application_identity": "Developer ID Application: Cemal Y. Dalar (4935L7XX5T)" 13 | }, 14 | "zip": { 15 | "output_path": "./dist/onctl_darwin_amd64.zip" 16 | } 17 | } -------------------------------------------------------------------------------- /gon/config-arm64.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": [ 3 | "./dist/onctl_darwin_arm64/onctl" 4 | ], 5 | "bundle_id": "net.dalar.onctl", 6 | "apple_id": { 7 | "username": "cdalar@me.com", 8 | "password": "@env:AC_PASSWORD", 9 | "provider": "CemalDalar182157262" 10 | }, 11 | "sign": { 12 | "application_identity": "Developer ID Application: Cemal Y. Dalar (4935L7XX5T)" 13 | }, 14 | "zip": { 15 | "output_path": "./dist/onctl_darwin_arm64.zip" 16 | } 17 | } -------------------------------------------------------------------------------- /internal/cloud/aws.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/cdalar/onctl/internal/tools" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/cdalar/onctl/internal/provideraws" 14 | 15 | "github.com/aws/aws-sdk-go/aws" 16 | "github.com/aws/aws-sdk-go/aws/awserr" 17 | "github.com/aws/aws-sdk-go/service/ec2" 18 | "golang.org/x/crypto/ssh" 19 | ) 20 | 21 | type ProviderAws struct { 22 | Client *ec2.EC2 23 | } 24 | 25 | func (p ProviderAws) Deploy(server Vm) (Vm, error) { 26 | if server.Type == "" { 27 | server.Type = viper.GetString("aws.vm.type") 28 | } 29 | images, err := provideraws.GetImages() 30 | if err != nil { 31 | log.Println(err) 32 | } 33 | 34 | keyPairs, err := p.Client.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{ 35 | KeyPairIds: []*string{aws.String(server.SSHKeyID)}, 36 | }) 37 | if err != nil { 38 | log.Fatalln(err) 39 | } 40 | 41 | vpcId := provideraws.GetDefaultVpcId(p.Client) 42 | log.Println("[DEBUG] VPC ID: ", vpcId) 43 | 44 | // securityGroupIds := []*string{} 45 | // sgIdForSSH := provideraws.CreateSecurityGroupSSH(p.Client, vpcId) 46 | // securityGroupIds = append(securityGroupIds, sgIdForSSH) 47 | // for _, port := range server.ExposePorts { 48 | // sgId := provideraws.CreateSecurityGroupForPort(p.Client, vpcId, port) 49 | // log.Println("[DEBUG] Security Group ID: ", sgId) 50 | // securityGroupIds = append(securityGroupIds, sgId) 51 | // } 52 | // log.Println("[DEBUG] Security Group Ids: ", securityGroupIds) 53 | input := &ec2.RunInstancesInput{ 54 | ImageId: aws.String(*images[0].ImageId), 55 | InstanceType: aws.String(server.Type), 56 | // InstanceMarketOptions: &ec2.InstanceMarketOptionsRequest{ 57 | // MarketType: aws.String("spot"), 58 | // SpotOptions: &ec2.SpotMarketOptions{ 59 | // MaxPrice: aws.String("0.02"), 60 | // }, 61 | // }, 62 | MinCount: aws.Int64(1), 63 | MaxCount: aws.Int64(1), 64 | KeyName: aws.String(*keyPairs.KeyPairs[0].KeyName), 65 | NetworkInterfaces: []*ec2.InstanceNetworkInterfaceSpecification{ 66 | { 67 | DeviceIndex: aws.Int64(0), 68 | // SubnetId: aws.String(subnetIds[0]), 69 | AssociatePublicIpAddress: aws.Bool(true), 70 | DeleteOnTermination: aws.Bool(true), 71 | // Groups: securityGroupIds, 72 | }, 73 | }, 74 | TagSpecifications: []*ec2.TagSpecification{ 75 | { 76 | ResourceType: aws.String("instance"), 77 | Tags: []*ec2.Tag{ 78 | { 79 | Key: aws.String("Name"), 80 | Value: aws.String(server.Name), 81 | }, 82 | { 83 | Key: aws.String("Owner"), 84 | Value: aws.String("onctl"), 85 | }, 86 | }, 87 | }, 88 | }, 89 | } 90 | 91 | descOut, err := p.Client.DescribeInstances(&ec2.DescribeInstancesInput{ 92 | Filters: []*ec2.Filter{ 93 | { 94 | Name: aws.String("tag:Name"), 95 | Values: []*string{aws.String(server.Name)}, 96 | }, 97 | { 98 | Name: aws.String("tag:Owner"), 99 | Values: []*string{aws.String("onctl")}, 100 | }, 101 | { 102 | Name: aws.String("instance-state-name"), 103 | Values: []*string{aws.String("running")}, 104 | }, 105 | }, 106 | }) 107 | if err != nil { 108 | log.Fatalln(err) 109 | } 110 | if len(descOut.Reservations) > 0 { 111 | log.Println("Instance already exists, skipping creation") 112 | return mapAwsServer(descOut.Reservations[0].Instances[0]), nil 113 | } 114 | 115 | result, err := p.Client.RunInstances(input) 116 | if err != nil { 117 | if aerr, ok := err.(awserr.Error); ok { 118 | switch aerr.Code() { 119 | default: 120 | fmt.Println(aerr.Error()) 121 | } 122 | } else { 123 | fmt.Println(err.Error()) 124 | } 125 | return Vm{}, err 126 | } 127 | log.Println("[DEBUG] " + result.String()) 128 | err = p.Client.WaitUntilInstanceRunning(&ec2.DescribeInstancesInput{ 129 | InstanceIds: []*string{result.Instances[0].InstanceId}, 130 | }) 131 | if err != nil { 132 | log.Fatalln(err) 133 | } 134 | instance := provideraws.DescribeInstance(*result.Instances[0].InstanceId) 135 | 136 | return mapAwsServer(instance), nil 137 | } 138 | 139 | func (p ProviderAws) Destroy(server Vm) error { 140 | if server.ID == "" { 141 | log.Println("[DEBUG] Server ID is empty") 142 | s, err := p.GetByName(server.Name) 143 | if err != nil || s.ID == "" { 144 | log.Fatalln(err) 145 | } 146 | server.ID = s.ID 147 | } 148 | log.Println("[DEBUG] Terminating Instance: " + server.ID) 149 | input := &ec2.TerminateInstancesInput{ 150 | InstanceIds: []*string{ 151 | aws.String(server.ID), 152 | }, 153 | } 154 | result, err := p.Client.TerminateInstances(input) 155 | if err != nil { 156 | if aerr, ok := err.(awserr.Error); ok { 157 | switch aerr.Code() { 158 | default: 159 | fmt.Println(aerr.Error()) 160 | } 161 | } else { 162 | // Print the error, cast err to awserr.Error to get the Code and 163 | // Message from an error. 164 | fmt.Println(err.Error()) 165 | } 166 | return err 167 | } 168 | log.Println("[DEBUG] " + result.String()) 169 | return nil 170 | } 171 | 172 | func (p ProviderAws) List() (VmList, error) { 173 | 174 | input := &ec2.DescribeInstancesInput{ 175 | Filters: []*ec2.Filter{ 176 | { 177 | Name: aws.String("tag:Owner"), 178 | Values: []*string{aws.String("onctl")}, 179 | }, 180 | }, 181 | } 182 | instances, err := p.Client.DescribeInstances(input) 183 | if err != nil { 184 | if aerr, ok := err.(awserr.Error); ok { 185 | switch aerr.Code() { 186 | default: 187 | fmt.Println(aerr.Error()) 188 | } 189 | } else { 190 | // Print the error, cast err to awserr.Error to get the Code and 191 | // Message from an error. 192 | fmt.Println(err.Error()) 193 | } 194 | return VmList{}, err 195 | } 196 | log.Println("[DEBUG] " + instances.String()) 197 | 198 | if len(instances.Reservations) > 0 { 199 | log.Println("[DEBUG] # of Instances:" + strconv.Itoa(len(instances.Reservations[0].Instances))) 200 | log.Println("[DEBUG] # of Reservations:" + strconv.Itoa(len(instances.Reservations))) 201 | cloudList := make([]Vm, 0, len(instances.Reservations)) 202 | for _, reserv := range instances.Reservations { 203 | cloudList = append(cloudList, mapAwsServer(reserv.Instances[0])) 204 | } 205 | output := VmList{ 206 | List: cloudList, 207 | } 208 | return output, nil 209 | } 210 | return VmList{}, nil 211 | } 212 | 213 | func (p ProviderAws) CreateSSHKey(publicKeyFile string) (keyID string, err error) { 214 | publicKey, err := os.ReadFile(publicKeyFile) 215 | if err != nil { 216 | log.Fatalln(err) 217 | } 218 | 219 | SSHKeyMD5 := fmt.Sprintf("%x", md5.Sum(publicKey)) 220 | pk, _, _, _, err := ssh.ParseAuthorizedKey(publicKey) 221 | if err != nil { 222 | panic(err) 223 | } 224 | 225 | // Get the fingerprint 226 | SSHKeyFingerPrint := ssh.FingerprintLegacyMD5(pk) 227 | 228 | // Print the fingerprint 229 | log.Println("[DEBUG] SSH Key Fingerpring: " + SSHKeyFingerPrint) 230 | log.Println("[DEBUG] SSH Key MD5: " + SSHKeyMD5) 231 | importKeyPairOutput, err := p.Client.ImportKeyPair(&ec2.ImportKeyPairInput{ 232 | PublicKeyMaterial: publicKey, 233 | KeyName: aws.String("onctl-" + SSHKeyMD5[:8]), 234 | }) 235 | log.Println("[DEBUG] " + importKeyPairOutput.String()) 236 | if err != nil { 237 | if aerr, ok := err.(awserr.Error); ok { 238 | log.Println("[DEBUG] AWS Error: " + aerr.Code()) 239 | switch aerr.Code() { 240 | case "InvalidKeyPair.Duplicate": 241 | log.Println("[DEBUG] SSH Key already exists") 242 | keyPair, err := p.Client.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{ 243 | KeyNames: []*string{aws.String("onctl-" + SSHKeyMD5[:8])}, 244 | }) 245 | if err != nil { 246 | log.Fatalln(err) 247 | } 248 | log.Println("[DEBUG] SSH Key ID: " + *keyPair.KeyPairs[0].KeyPairId) 249 | return *keyPair.KeyPairs[0].KeyPairId, nil 250 | default: 251 | fmt.Println(aerr.Error()) 252 | } 253 | } else { 254 | fmt.Println(err.Error()) 255 | } 256 | log.Fatalln(err) 257 | } 258 | return *importKeyPairOutput.KeyPairId, nil 259 | } 260 | 261 | func mapAwsServer(server *ec2.Instance) Vm { 262 | var serverName = "" 263 | 264 | for _, tag := range server.Tags { 265 | if *tag.Key == "Name" { 266 | serverName = *tag.Value 267 | } 268 | } 269 | // log.Println("[DEBUG] " + server.String()) 270 | if server.PublicIpAddress == nil { 271 | server.PublicIpAddress = aws.String("") 272 | } 273 | if server.PrivateIpAddress == nil { 274 | server.PrivateIpAddress = aws.String("") 275 | } 276 | return Vm{ 277 | Provider: "aws", 278 | ID: *server.InstanceId, 279 | Name: serverName, 280 | IP: *server.PublicIpAddress, 281 | PrivateIP: *server.PrivateIpAddress, 282 | Type: *server.InstanceType, 283 | Status: *server.State.Name, 284 | CreatedAt: *server.LaunchTime, 285 | Location: *server.Placement.AvailabilityZone, 286 | Cost: CostStruct{ 287 | Currency: "N/A", 288 | CostPerHour: 0, 289 | CostPerMonth: 0, 290 | AccumulatedCost: 0, 291 | }, 292 | } 293 | } 294 | 295 | func (p ProviderAws) GetByName(serverName string) (Vm, error) { 296 | s, err := p.Client.DescribeInstances(&ec2.DescribeInstancesInput{ 297 | Filters: []*ec2.Filter{ 298 | { 299 | Name: aws.String("tag:Name"), 300 | Values: []*string{aws.String(serverName)}, 301 | }, 302 | { 303 | Name: aws.String("tag:Owner"), 304 | Values: []*string{aws.String("onctl")}, 305 | }, 306 | { 307 | Name: aws.String("instance-state-name"), 308 | Values: []*string{aws.String("running")}, 309 | }, 310 | }, 311 | }) 312 | if err != nil { 313 | log.Fatalln(err) 314 | } 315 | if len(s.Reservations) == 0 { 316 | // fmt.Println("No server found with name: " + serverName) 317 | // os.Exit(1) 318 | return Vm{}, err 319 | } 320 | return mapAwsServer(s.Reservations[0].Instances[0]), nil 321 | } 322 | 323 | func (p ProviderAws) SSHInto(serverName string, port int, privateKey string) { 324 | 325 | s, err := p.GetByName(serverName) 326 | if err != nil || s.ID == "" { 327 | log.Fatalln(err) 328 | } 329 | log.Println("[DEBUG] " + s.String()) 330 | 331 | if privateKey == "" { 332 | privateKey = viper.GetString("ssh.privateKey") 333 | } 334 | tools.SSHIntoVM(tools.SSHIntoVMRequest{ 335 | IPAddress: s.IP, 336 | User: viper.GetString("aws.vm.username"), 337 | Port: port, 338 | PrivateKeyFile: privateKey, 339 | }) 340 | } 341 | -------------------------------------------------------------------------------- /internal/cloud/azure.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/cdalar/onctl/internal/tools" 13 | 14 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" 15 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 16 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5" 17 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5" 18 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" 19 | "github.com/spf13/viper" 20 | ) 21 | 22 | type ProviderAzure struct { 23 | ResourceGraphClient *armresourcegraph.Client 24 | VmClient *armcompute.VirtualMachinesClient 25 | NicClient *armnetwork.InterfacesClient 26 | PublicIPClient *armnetwork.PublicIPAddressesClient 27 | SSHKeyClient *armcompute.SSHPublicKeysClient 28 | VnetClient *armnetwork.VirtualNetworksClient 29 | } 30 | 31 | type QueryResponse struct { 32 | TotalRecords *int64 33 | Data map[string]interface { 34 | } 35 | } 36 | 37 | func (p ProviderAzure) List() (VmList, error) { 38 | log.Println("[DEBUG] List Servers") 39 | query := ` 40 | resources 41 | | where type =~ 'microsoft.compute/virtualmachines' and resourceGroup =~ '` + viper.GetString("azure.resourceGroup") + `' 42 | | extend nics=array_length(properties.networkProfile.networkInterfaces) 43 | | mv-expand nic=properties.networkProfile.networkInterfaces 44 | | where nics == 1 or nic.properties.primary =~ 'true' or isempty(nic) 45 | | project vmId = id, vmName = name, vmSize=tostring(properties.hardwareProfile.vmSize), nicId = tostring(nic.id), timeCreated = tostring(properties.timeCreated), status = tostring(properties.extended.instanceView.powerState.displayStatus), location = tostring(location) 46 | | join kind=leftouter ( 47 | resources 48 | | where type =~ 'microsoft.network/networkinterfaces' 49 | | extend ipConfigsCount=array_length(properties.ipConfigurations) 50 | | mv-expand ipconfig=properties.ipConfigurations 51 | | where ipConfigsCount == 1 or ipconfig.properties.primary =~ 'true' 52 | | project nicId = id, publicIpId = tostring(ipconfig.properties.publicIPAddress.id), privateIp = tostring(ipconfig.properties.privateIPAddress)) 53 | on nicId 54 | | project-away nicId1 55 | | summarize by vmId, vmName, vmSize, nicId, publicIpId, privateIp, timeCreated, status, location 56 | | join kind=leftouter ( 57 | resources 58 | | where type =~ 'microsoft.network/publicipaddresses' 59 | | project publicIpId = id, publicIpAddress = properties.ipAddress) 60 | on publicIpId 61 | | project-away publicIpId1 62 | | order by timeCreated asc 63 | ` 64 | // Create the query request, Run the query and get the results. Update the VM and subscriptionID details below. 65 | resp, err := p.ResourceGraphClient.Resources(context.Background(), 66 | armresourcegraph.QueryRequest{ 67 | Query: to.Ptr(query), 68 | Subscriptions: []*string{ 69 | to.Ptr(viper.GetString("azure.subscriptionId"))}, 70 | Options: &armresourcegraph.QueryRequestOptions{ 71 | ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray), 72 | }, 73 | }, 74 | nil) 75 | if err != nil { 76 | log.Fatalf("failed to finish the request: %v", err) 77 | } else { 78 | // Print the obtained query results 79 | log.Printf("[DEBUG] Resources found: %d\n", *resp.TotalRecords) 80 | log.Printf("[DEBUG] Results: %v\n", resp.Data) 81 | } 82 | if len(strconv.FormatInt(*resp.TotalRecords, 10)) == 0 { 83 | return VmList{}, nil 84 | } 85 | log.Println("[DEBUG] ", resp.Data) 86 | cloudList := make([]Vm, 0, len(strconv.FormatInt(*resp.TotalRecords, 10))) 87 | if m, ok := resp.Data.([]interface{}); ok { 88 | for _, r := range m { 89 | items := r.(map[string]interface{}) 90 | createdAt, err := time.Parse("2006-01-02T15:04:05Z", items["timeCreated"].(string)) 91 | if err != nil { 92 | log.Fatalln(err) 93 | } 94 | if items["publicIpAddress"] == nil { 95 | items["publicIpAddress"] = "N/A" 96 | } 97 | 98 | cloudList = append(cloudList, Vm{ 99 | Provider: "azure", 100 | ID: filepath.Base(items["vmId"].(string)), 101 | Name: items["vmName"].(string), 102 | IP: items["publicIpAddress"].(string), 103 | PrivateIP: items["privateIp"].(string), 104 | Type: items["vmSize"].(string), 105 | Status: items["status"].(string), 106 | CreatedAt: createdAt, 107 | Location: items["location"].(string), 108 | Cost: CostStruct{ 109 | Currency: "N/A", 110 | CostPerHour: 0, 111 | CostPerMonth: 0, 112 | AccumulatedCost: 0, 113 | }, 114 | }) 115 | } 116 | } 117 | 118 | output := VmList{ 119 | List: cloudList, 120 | } 121 | return output, nil 122 | } 123 | 124 | func (p ProviderAzure) CreateSSHKey(publicKeyFileName string) (string, error) { 125 | log.Println("[DEBUG] Create SSH Key") 126 | 127 | username := tools.GenerateUserName() 128 | sshPublicKeyData, err := os.ReadFile(publicKeyFileName) 129 | if err != nil { 130 | log.Println(err) 131 | } 132 | // Create the SSH Key 133 | sshKey, err := p.SSHKeyClient.Create(context.Background(), viper.GetString("azure.resourceGroup"), username, armcompute.SSHPublicKeyResource{ 134 | Properties: &armcompute.SSHPublicKeyResourceProperties{ 135 | PublicKey: to.Ptr(string(sshPublicKeyData[:])), 136 | }, 137 | Location: to.Ptr(viper.GetString("azure.location")), 138 | }, nil) 139 | if err != nil { 140 | log.Fatalln(err) 141 | } 142 | return *sshKey.ID, err 143 | } 144 | 145 | func (p ProviderAzure) getSSHKeyPublicData() string { 146 | userName := tools.GenerateUserName() 147 | sshKey, err := p.SSHKeyClient.Get(context.Background(), viper.GetString("azure.resourceGroup"), userName, nil) 148 | if err != nil { 149 | log.Println(err) 150 | } 151 | log.Println("[DEBUG] ", sshKey) 152 | return *sshKey.Properties.PublicKey 153 | } 154 | 155 | func (p ProviderAzure) Deploy(server Vm) (Vm, error) { 156 | log.Println("[DEBUG] Deploy Server") 157 | 158 | var vnet *armnetwork.VirtualNetwork 159 | var err error 160 | // Create the Vnet 161 | if viper.GetString("azure.vm.vnet.create") == "true" { 162 | vnet, err = createVirtualNetwork(context.Background(), &p) 163 | if err != nil { 164 | log.Fatalln(err) 165 | } 166 | log.Println("[DEBUG] ", vnet) 167 | } else { // Get the Vnet 168 | vnetResp, err := p.VnetClient.Get(context.Background(), viper.GetString("azure.resourceGroup"), viper.GetString("azure.vm.vnet.name"), nil) 169 | if err != nil { 170 | log.Fatalln(err) 171 | } 172 | log.Println("[DEBUG] ", vnetResp) 173 | vnet = &vnetResp.VirtualNetwork 174 | } 175 | pip, err := createPublicIP(context.Background(), &p, server) 176 | if err != nil { 177 | log.Fatalln(err) 178 | } 179 | log.Println("[DEBUG] ", pip) 180 | nic, err := createNic(context.Background(), &p, server, vnet, pip) 181 | if err != nil { 182 | log.Fatalln(err) 183 | } 184 | log.Println("[DEBUG] ", nic) 185 | 186 | // Create the VM 187 | vmDefinition := armcompute.VirtualMachine{ 188 | Location: to.Ptr(viper.GetString("azure.location")), 189 | Properties: &armcompute.VirtualMachineProperties{ 190 | HardwareProfile: &armcompute.HardwareProfile{ 191 | VMSize: to.Ptr(armcompute.VirtualMachineSizeTypes(viper.GetString("azure.vm.type"))), 192 | }, 193 | StorageProfile: &armcompute.StorageProfile{ 194 | ImageReference: &armcompute.ImageReference{ 195 | Publisher: to.Ptr(viper.GetString("azure.vm.image.publisher")), 196 | Offer: to.Ptr(viper.GetString("azure.vm.image.offer")), 197 | Version: to.Ptr(viper.GetString("azure.vm.image.version")), 198 | SKU: to.Ptr(viper.GetString("azure.vm.image.sku")), 199 | }, 200 | OSDisk: &armcompute.OSDisk{ 201 | DiffDiskSettings: &armcompute.DiffDiskSettings{ 202 | Option: to.Ptr(armcompute.DiffDiskOptionsLocal), 203 | Placement: to.Ptr(armcompute.DiffDiskPlacementResourceDisk), 204 | }, 205 | Caching: to.Ptr(armcompute.CachingTypesReadOnly), 206 | CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage), 207 | }, 208 | }, 209 | NetworkProfile: &armcompute.NetworkProfile{ 210 | NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ 211 | { 212 | ID: nic.ID, 213 | Properties: &armcompute.NetworkInterfaceReferenceProperties{ 214 | Primary: to.Ptr(true), 215 | }, 216 | }, 217 | }, 218 | }, 219 | OSProfile: &armcompute.OSProfile{ 220 | CustomData: to.Ptr(tools.FileToBase64(server.CloudInitFile)), 221 | ComputerName: to.Ptr(tools.GenerateMachineUniqueName()), 222 | AdminUsername: to.Ptr(viper.GetString("azure.vm.username")), 223 | LinuxConfiguration: &armcompute.LinuxConfiguration{ 224 | DisablePasswordAuthentication: to.Ptr(true), 225 | SSH: &armcompute.SSHConfiguration{ 226 | PublicKeys: []*armcompute.SSHPublicKey{ 227 | { 228 | KeyData: to.Ptr(p.getSSHKeyPublicData()), 229 | Path: to.Ptr("/home/" + viper.GetString("azure.vm.username") + "/.ssh/authorized_keys"), 230 | }, 231 | }, 232 | }, 233 | }, 234 | }, 235 | }, 236 | } 237 | 238 | if viper.GetString("azure.vm.priority") == "Spot" { 239 | vmDefinition.Properties.Priority = to.Ptr(armcompute.VirtualMachinePriorityTypesSpot) 240 | vmDefinition.Properties.EvictionPolicy = to.Ptr(armcompute.VirtualMachineEvictionPolicyTypesDelete) 241 | vmDefinition.Properties.BillingProfile = &armcompute.BillingProfile{MaxPrice: to.Ptr(0.1)} 242 | } 243 | 244 | poller, err := p.VmClient.BeginCreateOrUpdate(context.Background(), viper.GetString("azure.resourceGroup"), server.Name, vmDefinition, nil) 245 | if err != nil { 246 | log.Fatalln(err) 247 | } 248 | resp, err := poller.PollUntilDone(context.Background(), &runtime.PollUntilDoneOptions{ 249 | Frequency: time.Duration(3) * time.Second, 250 | }) 251 | return Vm{ 252 | ID: *resp.VirtualMachine.Properties.VMID, 253 | Name: *resp.VirtualMachine.Name, 254 | IP: *pip.Properties.IPAddress, 255 | Type: string(*resp.VirtualMachine.Properties.HardwareProfile.VMSize), 256 | Status: *resp.VirtualMachine.Properties.ProvisioningState, 257 | CreatedAt: *resp.VirtualMachine.Properties.TimeCreated, 258 | }, err 259 | } 260 | 261 | func (p ProviderAzure) Destroy(server Vm) error { 262 | log.Println("[DEBUG] Destroy Server") 263 | resp, err := p.VmClient.BeginDelete(context.Background(), viper.GetString("azure.resourceGroup"), server.Name, nil) 264 | if err != nil { 265 | log.Fatalln(err) 266 | } 267 | log.Println("[DEBUG] ", resp) 268 | 269 | respDone, err := resp.PollUntilDone(context.Background(), &runtime.PollUntilDoneOptions{ 270 | Frequency: time.Duration(3) * time.Second, 271 | }) 272 | if err != nil { 273 | log.Fatalln(err) 274 | } 275 | log.Println("[DEBUG] ", respDone) 276 | if resp.Done() { 277 | log.Println("[DEBUG] DONE") 278 | } 279 | 280 | nic, err := p.NicClient.BeginDelete(context.Background(), viper.GetString("azure.resourceGroup"), server.Name+"-nic", nil) 281 | if err != nil { 282 | log.Fatalln(err) 283 | } 284 | nicDone, err := nic.PollUntilDone(context.Background(), &runtime.PollUntilDoneOptions{ 285 | Frequency: time.Duration(3) * time.Second, 286 | }) 287 | if err != nil { 288 | log.Fatalln(err) 289 | } 290 | log.Println("[DEBUG] ", nicDone) 291 | if nic.Done() { 292 | log.Println("[DEBUG] DONE") 293 | } 294 | pip, err := p.PublicIPClient.BeginDelete(context.Background(), viper.GetString("azure.resourceGroup"), server.Name+"-pip", nil) 295 | if err != nil { 296 | log.Fatalln(err) 297 | } 298 | if err != nil { 299 | log.Fatalln(err) 300 | } 301 | log.Println("[DEBUG] ", pip) 302 | 303 | return err 304 | 305 | } 306 | 307 | func (p ProviderAzure) SSHInto(serverName string, port int, privateKey string) { 308 | s, err := p.GetByName(serverName) 309 | if err != nil || s.ID == "" { 310 | log.Fatalln(err) 311 | } 312 | log.Println("[DEBUG] " + s.String()) 313 | 314 | if privateKey == "" { 315 | privateKey = viper.GetString("ssh.privateKey") 316 | } 317 | tools.SSHIntoVM(tools.SSHIntoVMRequest{ 318 | IPAddress: s.IP, 319 | User: viper.GetString("azure.vm.username"), 320 | Port: port, 321 | PrivateKeyFile: privateKey, 322 | }) 323 | 324 | } 325 | 326 | func (p ProviderAzure) GetByName(serverName string) (Vm, error) { 327 | vmList, err := p.List() 328 | if err != nil { 329 | return Vm{}, err 330 | } 331 | for _, vm := range vmList.List { 332 | if vm.Name == serverName { 333 | return vm, nil 334 | } 335 | } 336 | return Vm{}, nil 337 | } 338 | 339 | func createVirtualNetwork(ctx context.Context, p *ProviderAzure) (*armnetwork.VirtualNetwork, error) { 340 | parameters := armnetwork.VirtualNetwork{ 341 | Location: to.Ptr(viper.GetString("azure.location")), 342 | Properties: &armnetwork.VirtualNetworkPropertiesFormat{ 343 | AddressSpace: &armnetwork.AddressSpace{ 344 | AddressPrefixes: []*string{ 345 | to.Ptr(viper.GetString("azure.vm.vnet.cidr")), 346 | }, 347 | }, 348 | Subnets: []*armnetwork.Subnet{ 349 | { 350 | Name: to.Ptr(viper.GetString("azure.vm.vnet.subnet.name")), 351 | Properties: &armnetwork.SubnetPropertiesFormat{ 352 | AddressPrefix: to.Ptr(viper.GetString("azure.vm.vnet.subnet.cidr")), 353 | }, 354 | }, 355 | }, 356 | }, 357 | } 358 | fmt.Print("Creating virtual network...") 359 | pollerResponse, err := p.VnetClient.BeginCreateOrUpdate(ctx, viper.GetString("azure.resourceGroup"), viper.GetString("azure.vm.vnet.name"), parameters, nil) 360 | if err != nil { 361 | return nil, err 362 | } 363 | 364 | resp, err := pollerResponse.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ 365 | Frequency: time.Duration(3) * time.Second, 366 | }) 367 | if err != nil { 368 | return nil, err 369 | } 370 | 371 | if pollerResponse.Done() { 372 | log.Println("[DEBUG] DONE") 373 | } 374 | 375 | return &resp.VirtualNetwork, nil 376 | } 377 | 378 | func createPublicIP(ctx context.Context, p *ProviderAzure, server Vm) (*armnetwork.PublicIPAddress, error) { 379 | 380 | parameters := armnetwork.PublicIPAddress{ 381 | Location: to.Ptr(viper.GetString("azure.location")), 382 | Properties: &armnetwork.PublicIPAddressPropertiesFormat{ 383 | PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic), // Static or Dynamic 384 | }, 385 | } 386 | fmt.Print("Creating public IP...") 387 | pollerResponse, err := p.PublicIPClient.BeginCreateOrUpdate(ctx, viper.GetString("azure.resourceGroup"), server.Name+"-pip", parameters, nil) 388 | if err != nil { 389 | return nil, err 390 | } 391 | 392 | resp, err := pollerResponse.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ 393 | Frequency: time.Duration(3) * time.Second, 394 | }) 395 | if err != nil { 396 | return nil, err 397 | } 398 | if pollerResponse.Done() { 399 | log.Println("[DEBUG] DONE") 400 | } 401 | 402 | return &resp.PublicIPAddress, err 403 | } 404 | 405 | func createNic(ctx context.Context, p *ProviderAzure, server Vm, vnet *armnetwork.VirtualNetwork, pip *armnetwork.PublicIPAddress) (*armnetwork.Interface, error) { 406 | fmt.Print("Creating network interface...") 407 | nicResp, err := p.NicClient.BeginCreateOrUpdate(context.Background(), viper.GetString("azure.resourceGroup"), server.Name+"-nic", armnetwork.Interface{ 408 | Location: to.Ptr(viper.GetString("azure.location")), 409 | Properties: &armnetwork.InterfacePropertiesFormat{ 410 | IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ 411 | { 412 | Name: to.Ptr("ipConfig"), 413 | Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ 414 | PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodDynamic), 415 | Subnet: &armnetwork.Subnet{ 416 | ID: vnet.Properties.Subnets[0].ID, 417 | }, 418 | PublicIPAddress: &armnetwork.PublicIPAddress{ 419 | ID: pip.ID, 420 | }, 421 | }, 422 | }, 423 | }, 424 | }, 425 | }, nil) 426 | if err != nil { 427 | log.Fatalln(err) 428 | } 429 | nicRespDone, err := nicResp.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ 430 | Frequency: time.Duration(3) * time.Second, 431 | }) 432 | if err != nil { 433 | log.Fatalln(err) 434 | } 435 | if nicResp.Done() { 436 | log.Println("[DEBUG] DONE") 437 | } 438 | 439 | log.Println("[DEBUG] ", nicRespDone) 440 | return &nicRespDone.Interface, err 441 | 442 | } 443 | -------------------------------------------------------------------------------- /internal/cloud/cloud.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "time" 7 | ) 8 | 9 | type VmList struct { 10 | List []Vm 11 | } 12 | 13 | type Price struct { 14 | // Currency is the currency of the price 15 | Currency string 16 | // Hourly is the hourly price 17 | Hourly string 18 | // Monthly is the monthly price 19 | Monthly string 20 | } 21 | 22 | type Vm struct { 23 | // ID is the ID of the instance 24 | ID string 25 | // Name is the name of the instance 26 | Name string 27 | // IP is the public IP of the instance 28 | IP string 29 | //LocalIP is the local IP of the instance 30 | PrivateIP string 31 | // Type is the type of the instance 32 | Type string 33 | // Status is the status of the instance 34 | Status string 35 | // Location is the location of the instance 36 | Location string 37 | // SSHKeyID is the ID of the SSH key 38 | SSHKeyID string 39 | // SSHPort is the port to connect to the instance 40 | SSHPort int 41 | // CloudInit is the cloud-init file 42 | CloudInitFile string 43 | // CreatedAt is the creation date of the instance 44 | CreatedAt time.Time 45 | // Provider is the cloud provider 46 | Provider string 47 | // Cost is the cost of the vm 48 | Cost CostStruct 49 | } 50 | 51 | type CostStruct struct { 52 | Currency string 53 | CostPerHour float64 54 | CostPerMonth float64 55 | AccumulatedCost float64 56 | } 57 | 58 | func (v Vm) String() string { 59 | value := reflect.ValueOf(v) 60 | typeOfS := value.Type() 61 | var ret string = "\n" 62 | for i := 0; i < value.NumField(); i++ { 63 | ret = ret + fmt.Sprintf("%s:\t %v\n", typeOfS.Field(i).Name, value.Field(i).Interface()) 64 | } 65 | return ret 66 | } 67 | 68 | type CloudProviderInterface interface { 69 | // Deploy deploys a new instance 70 | Deploy(Vm) (Vm, error) 71 | // Destroy destroys an instance 72 | Destroy(Vm) error 73 | // List lists all instances 74 | List() (VmList, error) 75 | // CreateSSHKey creates a new SSH key 76 | CreateSSHKey(publicKeyFile string) (keyID string, err error) 77 | // SSHInto connects to a VM 78 | SSHInto(serverName string, port int, privateKey string) 79 | // GetByName gets a VM by name 80 | GetByName(serverName string) (Vm, error) 81 | } 82 | -------------------------------------------------------------------------------- /internal/cloud/gcp.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "time" 11 | 12 | compute "cloud.google.com/go/compute/apiv1" 13 | "cloud.google.com/go/compute/apiv1/computepb" 14 | "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 15 | "github.com/cdalar/onctl/internal/tools" 16 | "github.com/spf13/viper" 17 | "google.golang.org/api/iterator" 18 | ) 19 | 20 | var ( 21 | publicKey []byte 22 | ) 23 | 24 | type ProviderGcp struct { 25 | Client *compute.InstancesClient 26 | GroupClient *compute.InstanceGroupsClient 27 | } 28 | 29 | func (p ProviderGcp) List() (VmList, error) { 30 | log.Println("[DEBUG] List Servers") 31 | cloudList := make([]Vm, 0, 100) 32 | it := p.Client.AggregatedList(context.Background(), &computepb.AggregatedListInstancesRequest{ 33 | Project: viper.GetString("gcp.project"), 34 | }) 35 | for { 36 | resp, err := it.Next() 37 | if err == iterator.Done { 38 | break 39 | } 40 | if err != nil { 41 | log.Fatalln(err) 42 | } 43 | for _, instance := range resp.Value.Instances { 44 | cloudList = append(cloudList, mapGcpServer(instance)) 45 | log.Println("[DEBUG] server name: " + *instance.Name) 46 | } 47 | _ = resp 48 | } 49 | output := VmList{ 50 | List: cloudList, 51 | } 52 | return output, nil 53 | } 54 | 55 | func (p ProviderGcp) CreateSSHKey(publicKeyFile string) (keyID string, err error) { 56 | publicKey, err = os.ReadFile(publicKeyFile) 57 | if err != nil { 58 | log.Fatalln(err) 59 | } 60 | return 61 | } 62 | 63 | func (p ProviderGcp) Destroy(server Vm) error { 64 | log.Println("[DEBUG] Destroy server: ", server) 65 | if server.ID == "" && server.Name != "" { 66 | log.Println("[DEBUG] Server ID is empty") 67 | log.Println("[DEBUG] Server name: " + server.Name) 68 | s, err := p.GetByName(server.Name) 69 | if err != nil || s.ID == "" { 70 | log.Println("[DEBUG] Server not found") 71 | return err 72 | } 73 | log.Println("[DEBUG] Server found ID: " + s.ID) 74 | server.ID = s.ID 75 | } 76 | opt, err := p.Client.Delete(context.Background(), &computepb.DeleteInstanceRequest{ 77 | Project: viper.GetString("gcp.project"), 78 | Zone: viper.GetString("gcp.zone"), 79 | Instance: server.Name, 80 | }) 81 | if err != nil { 82 | log.Fatalln(err) 83 | } 84 | log.Println("[DEBUG] Operation: ", opt) 85 | return nil 86 | } 87 | 88 | func (p ProviderGcp) GetByName(serverName string) (Vm, error) { 89 | log.Println("[DEBUG] Get Server by Name: ", serverName) 90 | server, err := p.Client.Get(context.Background(), &computepb.GetInstanceRequest{ 91 | Project: viper.GetString("gcp.project"), 92 | Zone: viper.GetString("gcp.zone"), 93 | Instance: serverName, 94 | }) 95 | if err != nil { 96 | log.Fatalln(err) 97 | } 98 | return mapGcpServer(server), nil 99 | 100 | } 101 | 102 | func (p ProviderGcp) Deploy(server Vm) (Vm, error) { 103 | 104 | machineType := fmt.Sprintf("zones/%s/machineTypes/%s", viper.GetString("gcp.zone"), viper.GetString("gcp.type")) 105 | op, err := p.Client.Insert(context.Background(), &computepb.InsertInstanceRequest{ 106 | Project: viper.GetString("gcp.project"), 107 | Zone: viper.GetString("gcp.zone"), 108 | InstanceResource: &computepb.Instance{ 109 | Name: &server.Name, 110 | MachineType: &machineType, 111 | Metadata: &computepb.Metadata{ 112 | Items: []*computepb.Items{ 113 | { 114 | Key: to.Ptr("ssh-keys"), 115 | Value: to.Ptr(fmt.Sprintf("%s:%s", viper.GetString("gcp.vm.username"), string(publicKey))), 116 | }, 117 | { 118 | Key: to.Ptr("user-data"), 119 | Value: to.Ptr(tools.FileToBase64(server.CloudInitFile)), 120 | }, 121 | }, 122 | }, 123 | Disks: []*computepb.AttachedDisk{ 124 | { 125 | AutoDelete: to.Ptr(true), 126 | Boot: to.Ptr(true), 127 | InitializeParams: &computepb.AttachedDiskInitializeParams{ 128 | SourceImage: to.Ptr("projects/ubuntu-os-cloud/global/images/ubuntu-2204-jammy-v20240208"), 129 | }, 130 | }, 131 | }, 132 | 133 | NetworkInterfaces: []*computepb.NetworkInterface{ 134 | { 135 | AccessConfigs: []*computepb.AccessConfig{ 136 | { 137 | Name: to.Ptr("External NAT"), 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }) 144 | if err != nil { 145 | log.Fatalln(err) 146 | } 147 | err = op.Wait(context.Background()) 148 | if err != nil { 149 | log.Fatalln(err) 150 | } 151 | return p.GetByName(server.Name) 152 | } 153 | 154 | func (p ProviderGcp) SSHInto(serverName string, port int, privateKey string) { 155 | server, err := p.GetByName(serverName) 156 | if err != nil { 157 | log.Fatalln(err) 158 | } 159 | if privateKey == "" { 160 | privateKey = viper.GetString("ssh.privateKey") 161 | } 162 | tools.SSHIntoVM(tools.SSHIntoVMRequest{ 163 | IPAddress: server.IP, 164 | User: viper.GetString("gcp.vm.username"), 165 | Port: port, 166 | PrivateKeyFile: privateKey, 167 | }) 168 | } 169 | 170 | // mapGcpServer maps a GCP server to a Vm struct 171 | func mapGcpServer(server *computepb.Instance) Vm { 172 | createdAt, err := time.Parse(time.RFC3339, *server.CreationTimestamp) 173 | if err != nil { 174 | log.Fatalln(err) 175 | } 176 | 177 | return Vm{ 178 | Provider: "gcp", 179 | ID: strconv.FormatUint(server.GetId(), 10), 180 | Name: server.GetName(), 181 | IP: server.GetNetworkInterfaces()[0].GetAccessConfigs()[0].GetNatIP(), 182 | // PrivateIP: server.GetNetworkInterfaces()[0].GetNetworkIP(), 183 | Type: filepath.Base(server.GetMachineType()), 184 | Status: server.GetStatus(), 185 | CreatedAt: createdAt, 186 | Location: filepath.Base(server.GetZone()), 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /internal/cloud/hetzner.go: -------------------------------------------------------------------------------- 1 | package cloud 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "math" 10 | "os" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/cdalar/onctl/internal/tools" 15 | 16 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 17 | "github.com/spf13/viper" 18 | "golang.org/x/crypto/ssh" 19 | ) 20 | 21 | type ProviderHetzner struct { 22 | Client *hcloud.Client 23 | } 24 | 25 | func (p ProviderHetzner) Deploy(server Vm) (Vm, error) { 26 | 27 | log.Println("[DEBUG] Deploy server: ", server) 28 | sshKeyIDint, err := strconv.ParseInt(server.SSHKeyID, 10, 64) 29 | if err != nil { 30 | log.Fatalln(err) 31 | } 32 | result, _, err := p.Client.Server.Create(context.TODO(), hcloud.ServerCreateOpts{ 33 | Name: server.Name, 34 | Location: &hcloud.Location{ 35 | Name: viper.GetString("hetzner.location"), 36 | }, 37 | Image: &hcloud.Image{ 38 | Name: viper.GetString("hetzner.vm.image"), 39 | }, 40 | ServerType: &hcloud.ServerType{ 41 | Name: viper.GetString("hetzner.vm.type"), 42 | }, 43 | SSHKeys: []*hcloud.SSHKey{ 44 | { 45 | ID: sshKeyIDint, 46 | }, 47 | }, 48 | Labels: map[string]string{ 49 | "Owner": "onctl", 50 | }, 51 | UserData: tools.FileToBase64(server.CloudInitFile), 52 | }) 53 | if err != nil { 54 | if herr, ok := err.(hcloud.Error); ok { 55 | switch herr.Code { 56 | case hcloud.ErrorCodeUniquenessError: 57 | log.Println("Server already exists") 58 | s, _, err := p.Client.Server.GetByName(context.TODO(), server.Name) 59 | if err != nil { 60 | log.Fatalln(err) 61 | } 62 | return mapHetznerServer(*s), nil 63 | default: 64 | fmt.Println(herr.Error()) 65 | } 66 | } else { 67 | fmt.Println(err.Error()) 68 | } 69 | log.Fatalln(err) 70 | } 71 | return mapHetznerServer(*result.Server), nil 72 | } 73 | 74 | func (p ProviderHetzner) Destroy(server Vm) error { 75 | log.Println("[DEBUG] Destroy server: ", server) 76 | if server.ID == "" && server.Name != "" { 77 | log.Println("[DEBUG] Server ID is empty") 78 | log.Println("[DEBUG] Server name: " + server.Name) 79 | s, err := p.GetByName(server.Name) 80 | if err != nil || s.ID == "" { 81 | log.Println("[DEBUG] Server not found") 82 | return err 83 | } 84 | log.Println("[DEBUG] Server found ID: " + s.ID) 85 | server.ID = s.ID 86 | } 87 | id, err := strconv.ParseInt(server.ID, 10, 64) 88 | if err != nil { 89 | log.Fatalln(err) 90 | } 91 | _, _, err = p.Client.Server.DeleteWithResult(context.TODO(), &hcloud.Server{ 92 | ID: id, 93 | }) 94 | if err != nil { 95 | log.Fatalln(err) 96 | } 97 | return nil 98 | } 99 | 100 | func (p ProviderHetzner) List() (VmList, error) { 101 | log.Println("[DEBUG] List Servers") 102 | list, _, err := p.Client.Server.List(context.TODO(), hcloud.ServerListOpts{ 103 | ListOpts: hcloud.ListOpts{ 104 | LabelSelector: "Owner=onctl", 105 | }, 106 | }) 107 | if err != nil { 108 | log.Println(err) 109 | } 110 | if len(list) == 0 { 111 | return VmList{}, nil 112 | } 113 | cloudList := make([]Vm, 0, len(list)) 114 | for _, server := range list { 115 | cloudList = append(cloudList, mapHetznerServer(*server)) 116 | log.Println("[DEBUG] server: ", server) 117 | } 118 | output := VmList{ 119 | List: cloudList, 120 | } 121 | return output, nil 122 | } 123 | 124 | func (p ProviderHetzner) CreateSSHKey(publicKeyFile string) (keyID string, err error) { 125 | publicKey, err := os.ReadFile(publicKeyFile) 126 | if err != nil { 127 | log.Fatalln(err) 128 | } 129 | 130 | SSHKeyMD5 := fmt.Sprintf("%x", md5.Sum(publicKey)) 131 | pk, _, _, _, err := ssh.ParseAuthorizedKey(publicKey) 132 | if err != nil { 133 | panic(err) 134 | } 135 | 136 | // Get the fingerprint 137 | SSHKeyFingerPrint := ssh.FingerprintLegacyMD5(pk) 138 | 139 | // Print the fingerprint 140 | log.Println("[DEBUG] SSH Key Fingerpring: " + SSHKeyFingerPrint) 141 | log.Println("[DEBUG] SSH Key MD5: " + SSHKeyMD5) 142 | // fmt.Println("Creating SSHKey: " + "onctl-" + SSHKeyMD5[:8] + "...") 143 | hkey, _, err := p.Client.SSHKey.Create(context.TODO(), hcloud.SSHKeyCreateOpts{ 144 | Name: "onctl-" + SSHKeyMD5[:8], 145 | PublicKey: string(publicKey), 146 | }) 147 | if err != nil { 148 | if herr, ok := err.(hcloud.Error); ok { 149 | switch herr.Code { 150 | case hcloud.ErrorCodeUniquenessError: 151 | log.Println("[DEBUG] SSH Key already exists (onctl-" + SSHKeyMD5[:8] + ")") 152 | key, _, err := p.Client.SSHKey.GetByFingerprint(context.TODO(), SSHKeyFingerPrint) 153 | if err != nil { 154 | log.Fatalln(err) 155 | } 156 | log.Println("[DEBUG] SSH Key ID: " + strconv.FormatInt(key.ID, 10)) 157 | return fmt.Sprint(key.ID), nil 158 | default: 159 | fmt.Println(herr.Error()) 160 | } 161 | } else { 162 | fmt.Println(err.Error()) 163 | } 164 | log.Fatalln(err) 165 | } 166 | // fmt.Println("DONE") 167 | return fmt.Sprint(hkey.ID), nil 168 | } 169 | 170 | // mapHetznerServer gets a hcloud.Server and returns a Vm 171 | func mapHetznerServer(server hcloud.Server) Vm { 172 | acculumatedCost := 0.0 173 | costPerHour := 0.0 174 | costPerMonth := 0.0 175 | currency := "EUR" 176 | for _, p := range server.ServerType.Pricings { 177 | if p.Location.Name == server.Datacenter.Location.Name { 178 | uptime := time.Since(server.Created) 179 | hourlyGross, _ := strconv.ParseFloat(p.Hourly.Gross, 64) // Convert p.Hourly.Gross to float64 180 | acculumatedCost = math.Round(hourlyGross*uptime.Hours()*10000) / 10000 181 | costPerHour, _ = strconv.ParseFloat(p.Hourly.Gross, 64) 182 | costPerMonth, _ = strconv.ParseFloat(p.Monthly.Gross, 64) 183 | } 184 | } 185 | var privateIP string 186 | if len(server.PrivateNet) == 0 { 187 | privateIP = "N/A" 188 | } else { 189 | privateIP = server.PrivateNet[0].IP.String() 190 | } 191 | 192 | return Vm{ 193 | Provider: "hetzner", 194 | ID: strconv.FormatInt(server.ID, 10), 195 | Name: server.Name, 196 | IP: server.PublicNet.IPv4.IP.String(), 197 | PrivateIP: privateIP, 198 | Type: server.ServerType.Name, 199 | Status: string(server.Status), 200 | CreatedAt: server.Created, 201 | Location: server.Datacenter.Location.Name, 202 | Cost: CostStruct{ 203 | Currency: currency, 204 | CostPerHour: costPerHour, 205 | CostPerMonth: costPerMonth, 206 | AccumulatedCost: acculumatedCost, 207 | }, 208 | } 209 | } 210 | 211 | func (p ProviderHetzner) GetByName(serverName string) (Vm, error) { 212 | s, _, err := p.Client.Server.GetByName(context.TODO(), serverName) 213 | if err != nil { 214 | return Vm{}, err 215 | } 216 | if s == nil { 217 | return Vm{}, errors.New("No Server found with name: " + serverName) 218 | } 219 | return mapHetznerServer(*s), nil 220 | } 221 | 222 | func (p ProviderHetzner) SSHInto(serverName string, port int, privateKey string) { 223 | server, _, err := p.Client.Server.GetByName(context.TODO(), serverName) 224 | if server == nil { 225 | fmt.Println("No Server found with name: " + serverName) 226 | os.Exit(1) 227 | } 228 | 229 | if err != nil { 230 | if herr, ok := err.(hcloud.Error); ok { 231 | switch herr.Code { 232 | case hcloud.ErrorCodeNotFound: 233 | log.Fatalln("Server not found") 234 | default: 235 | log.Fatalln(herr.Error()) 236 | } 237 | } else { 238 | log.Fatalln(err.Error()) 239 | } 240 | } 241 | 242 | if privateKey == "" { 243 | privateKey = viper.GetString("ssh.privateKey") 244 | } 245 | tools.SSHIntoVM(tools.SSHIntoVMRequest{ 246 | IPAddress: server.PublicNet.IPv4.IP.String(), 247 | User: viper.GetString("hetzner.vm.username"), 248 | Port: port, 249 | PrivateKeyFile: privateKey, 250 | }) 251 | } 252 | -------------------------------------------------------------------------------- /internal/domain/cloudflare.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "os" 8 | 9 | "github.com/cloudflare/cloudflare-go" 10 | ) 11 | 12 | type CloudFlareService struct { 13 | CLOUDFLARE_API_TOKEN string 14 | } 15 | 16 | func NewCloudFlareService() *CloudFlareService { 17 | apiToken := os.Getenv("CLOUDFLARE_API_TOKEN") 18 | if apiToken == "" { 19 | log.Println("[DEBUG] CLOUDFLARE_API_TOKEN is not set") 20 | } 21 | 22 | return &CloudFlareService{ 23 | CLOUDFLARE_API_TOKEN: apiToken, 24 | } 25 | } 26 | 27 | func (c *CloudFlareService) CheckEnv() error { 28 | apiToken := os.Getenv("CLOUDFLARE_API_TOKEN") 29 | if apiToken == "" { 30 | // log.Println("CLOUDFLARE_API_TOKEN is not set") 31 | return errors.New("CLOUDFLARE_API_TOKEN is not set") 32 | } 33 | zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") 34 | log.Println("[DEBUG] CLOUDFLARE_ZONE_ID:", zoneID) 35 | if zoneID == "" { 36 | // log.Println("CLOUDFLARE_ZONE_ID is not set") 37 | return errors.New("CLOUDFLARE_ZONE_ID is not set") 38 | } 39 | return nil 40 | } 41 | 42 | func (c *CloudFlareService) SetRecord(in *SetRecordRequest) (out *SetRecordResponse, err error) { 43 | // Call CloudFlare API to set domain 44 | // Construct a new API object using a global API key 45 | // api, err := cloudflare.New(os.Getenv("CLOUDFLARE_API_KEY"), os.Getenv("CLOUDFLARE_API_EMAIL")) 46 | // alternatively, you can use a scoped API token 47 | api, err := cloudflare.NewWithAPIToken(c.CLOUDFLARE_API_TOKEN) 48 | log.Println("[DEBUG] CLOUDFLARE_API_TOKEN:", c.CLOUDFLARE_API_TOKEN[:5]) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | // Most API calls require a Context 54 | ctx := context.Background() 55 | 56 | zoneID := os.Getenv("CLOUDFLARE_ZONE_ID") 57 | log.Println("[DEBUG] CLOUDFLARE_ZONE_ID:", zoneID) 58 | if zoneID == "" { 59 | log.Fatal("CLOUDFLARE_ZONE_ID is not set") 60 | } 61 | dnsRecords, _, err := api.ListDNSRecords(ctx, cloudflare.ResourceIdentifier(zoneID), cloudflare.ListDNSRecordsParams{}) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | log.Println("[DEBUG] dnsRecords:", dnsRecords) 66 | 67 | for _, record := range dnsRecords { 68 | log.Println("[DEBUG] record:", record.Name) 69 | if record.Name == in.Subdomain { 70 | log.Println("[DEBUG] Deleting record:", record.Name) 71 | err := api.DeleteDNSRecord(ctx, cloudflare.ResourceIdentifier(zoneID), record.ID) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | log.Println("[DEBUG] Deleted record:", record.Name) 76 | } 77 | } 78 | 79 | dnsRecord, err := api.CreateDNSRecord(ctx, cloudflare.ResourceIdentifier(zoneID), cloudflare.CreateDNSRecordParams{ 80 | Type: "A", 81 | // Name: GenerateRandomSubDomain(), 82 | Name: in.Subdomain, 83 | Proxied: cloudflare.BoolPtr(true), 84 | Content: in.Ipaddress, 85 | }) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | log.Println(dnsRecord) 90 | // Print user details 91 | // fmt.Println(dnsRecords) 92 | // for _, record := range dnsRecords { 93 | // fmt.Println(record.Name, record.Type, record.Content) 94 | // } 95 | 96 | return &SetRecordResponse{}, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/domain/domain.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | type DNSProvider interface { 4 | SetRecord(in *SetRecordRequest) (out *SetRecordResponse, err error) 5 | } 6 | 7 | type SetRecordRequest struct { 8 | Subdomain string 9 | Ipaddress string 10 | } 11 | 12 | type SetRecordResponse struct { 13 | } 14 | 15 | func New(domain string) DNSProvider { 16 | switch domain { 17 | case "cloudflare": 18 | return NewCloudFlareService() 19 | default: 20 | return nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/files/apply_dir.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | mkdir -p ~/$ONCTLDIR 5 | cd ~/$ONCTLDIR 6 | 7 | # Set the starting number 8 | max_num=-1 9 | 10 | # Check directories starting with 'apply' in the current directory 11 | for dir in apply[0-9][0-9]; do 12 | if [[ -d $dir ]]; then 13 | # Get the directory number 14 | num=${dir#apply} 15 | # Update the maximum number 16 | if ((10#$num > max_num)); then 17 | max_num=10#$num 18 | fi 19 | fi 20 | done 21 | 22 | # If there are no 'apply' directories, create apply00 23 | if [[ max_num -eq -1 ]]; then 24 | mkdir apply00 25 | echo -n "apply00" 26 | else 27 | # Calculate and format the next directory number 28 | next_num=$(printf "apply%02d" $((max_num + 1))) 29 | mkdir "$next_num" 30 | echo -n "$next_num" 31 | fi 32 | -------------------------------------------------------------------------------- /internal/files/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | traefik: 4 | image: "traefik:v2.10" 5 | container_name: "traefik" 6 | command: 7 | - "--log.level=DEBUG" 8 | - "--api.insecure=true" 9 | - "--providers.docker=true" 10 | - "--providers.docker.exposedbydefault=false" 11 | - "--entrypoints.web.address=:80" 12 | ports: 13 | - "80:80" 14 | - "8080:8080" 15 | volumes: 16 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 17 | web: 18 | image: "nginx:alpine" 19 | container_name: "nginx" 20 | labels: 21 | # Explicitly tell Traefik to expose this container 22 | - "traefik.enable=true" 23 | # # The domain the service will respond to 24 | - "traefik.http.routers.web.rule=PathPrefix(`/`)" 25 | # Allow request only from the predefined entry point named "web" 26 | - "traefik.http.routers.web.entrypoints=web" 27 | -------------------------------------------------------------------------------- /internal/files/docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | apt-get update 4 | apt-get install -y ca-certificates curl gnupg lsb-release 5 | mkdir -m 0755 -p /etc/apt/keyrings 6 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --yes --batch --dearmor -o /etc/apt/keyrings/docker.gpg 7 | echo \ 8 | "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ 9 | $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 10 | chmod a+r /etc/apt/keyrings/docker.gpg 11 | apt-get update 12 | apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 13 | # usermod -aG docker ubuntu -------------------------------------------------------------------------------- /internal/files/embed.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "embed" 4 | 5 | //go:embed * 6 | var EmbededFiles embed.FS 7 | -------------------------------------------------------------------------------- /internal/files/init/aws.yaml: -------------------------------------------------------------------------------- 1 | aws: 2 | location: eu-central-1 3 | vm: 4 | type: t2.micro 5 | username: ubuntu 6 | -------------------------------------------------------------------------------- /internal/files/init/azure.yaml: -------------------------------------------------------------------------------- 1 | azure: 2 | subscriptionId: 00000000-0000-0000-0000-000000000000 3 | resourceGroup: test 4 | location: westeurope 5 | vm: 6 | username: azureuser 7 | type: Standard_D4s_v3 8 | priority: Spot # Spot or Regular 9 | image: 10 | publisher: canonical 11 | offer: 0001-com-ubuntu-server-jammy 12 | version: latest 13 | sku: 22_04-lts-gen2 14 | vnet: 15 | create: true 16 | name: onctl-vnet 17 | cidr: 10.1.0.0/16 18 | subnet: 19 | name: onctl-subnet1 20 | cidr: 10.1.1.0/24 -------------------------------------------------------------------------------- /internal/files/init/gcp.yaml: -------------------------------------------------------------------------------- 1 | gcp: 2 | project: 3 | zone: europe-west4-a 4 | type: n1-standard-1 5 | vm: 6 | username: root 7 | -------------------------------------------------------------------------------- /internal/files/init/hetzner.yaml: -------------------------------------------------------------------------------- 1 | hetzner: 2 | location: fsn1 3 | vm: 4 | type: cpx21 5 | username: root 6 | image: ubuntu-22.04 7 | -------------------------------------------------------------------------------- /internal/files/init/onctl.yaml: -------------------------------------------------------------------------------- 1 | vm: 2 | name: onctl-vm 3 | cloud-init: 4 | timeout: 3m # ex. 3m or 180s 5 | ssh: 6 | # publicKey: ~/.ssh/id_ed25519.pub 7 | # privateKey: ~/.ssh/id_ed25519 8 | publicKey: ~/.ssh/id_rsa.pub 9 | privateKey: ~/.ssh/id_rsa 10 | -------------------------------------------------------------------------------- /internal/provideraws/common.go: -------------------------------------------------------------------------------- 1 | package provideraws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "log" 7 | "os" 8 | 9 | "github.com/cdalar/onctl/internal/tools" 10 | "github.com/spf13/viper" 11 | 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/aws/awserr" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/ec2" 16 | ) 17 | 18 | func SetDefaultRouteToMainRouteTable(svc *ec2.EC2, routeTableId *string, internetGatewayId *string) { 19 | 20 | input := &ec2.CreateRouteInput{ 21 | DestinationCidrBlock: aws.String("0.0.0.0/0"), // Required 22 | RouteTableId: routeTableId, // Required 23 | GatewayId: internetGatewayId, 24 | } 25 | 26 | _, err := svc.CreateRoute(input) 27 | if err != nil { 28 | if aerr, ok := err.(awserr.Error); ok { 29 | switch aerr.Code() { 30 | default: 31 | fmt.Println(aerr.Error()) 32 | } 33 | } else { 34 | // Print the error, cast err to awserr.Error to get the Code and 35 | // Message from an error. 36 | fmt.Println(err.Error()) 37 | } 38 | } 39 | } 40 | 41 | func DefaultRouteTable(svc *ec2.EC2, vpcId *string) *string { 42 | 43 | input := &ec2.DescribeRouteTablesInput{ 44 | Filters: []*ec2.Filter{ 45 | { 46 | Name: aws.String("vpc-id"), 47 | Values: []*string{vpcId}, 48 | }, 49 | }, 50 | } 51 | result, err := svc.DescribeRouteTables(input) 52 | if err != nil { 53 | if aerr, ok := err.(awserr.Error); ok { 54 | switch aerr.Code() { 55 | default: 56 | fmt.Println(aerr.Error()) 57 | } 58 | } else { 59 | // Print the error, cast err to awserr.Error to get the Code and 60 | // Message from an error. 61 | fmt.Println(err.Error()) 62 | } 63 | } 64 | return result.RouteTables[0].RouteTableId 65 | } 66 | 67 | func CreateSecurityGroupSSH(svc *ec2.EC2, vpcId *string) *string { 68 | 69 | sgs, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ 70 | Filters: []*ec2.Filter{ 71 | {Name: aws.String("tag:Name"), Values: []*string{aws.String("onkube-sg-ssh")}}}, 72 | }) 73 | if err != nil { 74 | if aerr, ok := err.(awserr.Error); ok { 75 | switch aerr.Code() { 76 | default: 77 | fmt.Println(aerr.Error()) 78 | } 79 | } else { 80 | // Print the error, cast err to awserr.Error to get the Code and 81 | // Message from an error. 82 | fmt.Println(err.Error()) 83 | } 84 | } 85 | 86 | if len(sgs.SecurityGroups) > 0 { 87 | log.Println("Security Group already exists for SSH") 88 | return sgs.SecurityGroups[0].GroupId 89 | } else { 90 | log.Println("Creating Security Group...") 91 | input := &ec2.CreateSecurityGroupInput{ 92 | Description: aws.String("onkube-sg-ssh"), // Required 93 | GroupName: aws.String("onkube-sg-ssh"), // Required 94 | VpcId: vpcId, // Required 95 | TagSpecifications: []*ec2.TagSpecification{ 96 | {ResourceType: aws.String("security-group"), Tags: []*ec2.Tag{{ 97 | Key: aws.String("Name"), Value: aws.String("onkube-sg-ssh")}}}, 98 | }, 99 | } 100 | result, err := svc.CreateSecurityGroup(input) 101 | if err != nil { 102 | if aerr, ok := err.(awserr.Error); ok { 103 | switch aerr.Code() { 104 | default: 105 | fmt.Println(aerr.Error()) 106 | } 107 | } else { 108 | // Print the error, cast err to awserr.Error to get the Code and 109 | // Message from an error. 110 | fmt.Println(err.Error()) 111 | } 112 | } 113 | _, err = svc.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ 114 | GroupId: result.GroupId, 115 | IpProtocol: aws.String("tcp"), 116 | FromPort: aws.Int64(22), 117 | ToPort: aws.Int64(22), 118 | CidrIp: aws.String("0.0.0.0/0"), 119 | }) 120 | if err != nil { 121 | log.Println(err) 122 | } 123 | // _, err = svc.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ 124 | // GroupId: result.GroupId, 125 | // IpProtocol: aws.String("tcp"), 126 | // FromPort: aws.Int64(80), 127 | // ToPort: aws.Int64(80), 128 | // CidrIp: aws.String("0.0.0.0/0"), 129 | // }) 130 | // if err != nil { 131 | // log.Println(err) 132 | // } 133 | 134 | log.Println("Security Group created: ", *result.GroupId) 135 | return result.GroupId 136 | } 137 | } 138 | 139 | func getAvailabilityZones(svc *ec2.EC2) []string { 140 | input := &ec2.DescribeAvailabilityZonesInput{} 141 | 142 | result, err := svc.DescribeAvailabilityZones(input) 143 | if err != nil { 144 | if aerr, ok := err.(awserr.Error); ok { 145 | switch aerr.Code() { 146 | default: 147 | fmt.Println(aerr.Error()) 148 | } 149 | } else { 150 | // Print the error, cast err to awserr.Error to get the Code and 151 | // Message from an error. 152 | fmt.Println(err.Error()) 153 | } 154 | return nil 155 | } 156 | var zones []string 157 | for _, zone := range result.AvailabilityZones { 158 | zones = append(zones, *zone.ZoneName) 159 | } 160 | return zones 161 | } 162 | 163 | func createSubnets(svc *ec2.EC2, vpcId string) []string { 164 | 165 | log.Println("Creating subnets...") 166 | var subnets = []string{"10.174.0.0/20", "10.174.16.0/20", "10.174.32.0/20"} 167 | subnetsAz := getAvailabilityZones(svc) 168 | var subnetIds []string 169 | for k, v := range subnets { 170 | 171 | input := &ec2.CreateSubnetInput{ 172 | CidrBlock: aws.String(v), // Required 173 | VpcId: aws.String(vpcId), // Required 174 | AvailabilityZone: aws.String(subnetsAz[k]), 175 | TagSpecifications: []*ec2.TagSpecification{ 176 | {ResourceType: aws.String("subnet"), Tags: []*ec2.Tag{{ 177 | Key: aws.String("Name"), Value: aws.String("onkube-subnet-" + subnetsAz[k])}}}, 178 | }} 179 | subnet, err := svc.CreateSubnet(input) 180 | if err != nil { 181 | if aerr, ok := err.(awserr.Error); ok { 182 | switch aerr.Code() { 183 | default: 184 | fmt.Println(aerr.Error()) 185 | } 186 | } else { 187 | // Print the error, cast err to awserr.Error to get the Code and 188 | // Message from an error. 189 | fmt.Println(err.Error()) 190 | } 191 | } 192 | subnetIds = append(subnetIds, *subnet.Subnet.SubnetId) 193 | } 194 | log.Println("Subnets created: ", subnetIds) 195 | return subnetIds 196 | } 197 | 198 | func AttachInternetGateway(svc *ec2.EC2, vpcId *string, internetGatewayId *string) { 199 | 200 | igws, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{ 201 | Filters: []*ec2.Filter{ 202 | {Name: aws.String("tag:Name"), Values: []*string{aws.String("onkube-internet-gateway")}}}, 203 | }) 204 | if err != nil { 205 | if aerr, ok := err.(awserr.Error); ok { 206 | switch aerr.Code() { 207 | default: 208 | fmt.Println(aerr.Error()) 209 | } 210 | } else { 211 | // Print the error, cast err to awserr.Error to get the Code and 212 | // Message from an error. 213 | fmt.Println(err.Error()) 214 | } 215 | } 216 | if len(igws.InternetGateways) > 0 { 217 | if len(igws.InternetGateways[0].Attachments) > 0 { 218 | if *igws.InternetGateways[0].Attachments[0].State == "available" { 219 | log.Println("InternetGateway already attached") 220 | return 221 | } 222 | } 223 | } 224 | 225 | input := &ec2.AttachInternetGatewayInput{ 226 | InternetGatewayId: internetGatewayId, // Required 227 | VpcId: vpcId, // Required 228 | } 229 | _, err = svc.AttachInternetGateway(input) 230 | if err != nil { 231 | if aerr, ok := err.(awserr.Error); ok { 232 | switch aerr.Code() { 233 | default: 234 | fmt.Println(aerr.Error()) 235 | } 236 | } else { 237 | // Print the error, cast err to awserr.Error to get the Code and 238 | // Message from an error. 239 | fmt.Println(err.Error()) 240 | } 241 | } 242 | } 243 | 244 | func CreateInternetGateway(svc *ec2.EC2) *string { 245 | 246 | igws_input := &ec2.DescribeInternetGatewaysInput{ 247 | Filters: []*ec2.Filter{ 248 | {Name: aws.String("tag:Name"), Values: []*string{aws.String("onkube-internet-gateway")}}}, 249 | } 250 | 251 | igws, err := svc.DescribeInternetGateways(igws_input) 252 | if err != nil { 253 | if aerr, ok := err.(awserr.Error); ok { 254 | switch aerr.Code() { 255 | default: 256 | fmt.Println(aerr.Error()) 257 | } 258 | } else { 259 | // Print the error, cast err to awserr.Error to get the Code and 260 | // Message from an error. 261 | fmt.Println(err.Error()) 262 | } 263 | } 264 | 265 | if len(igws.InternetGateways) > 0 { 266 | log.Println("InternetGateway found. using it...") 267 | return igws.InternetGateways[0].InternetGatewayId 268 | } 269 | 270 | log.Println("Creating InternetGateway...") 271 | input := &ec2.CreateInternetGatewayInput{ 272 | TagSpecifications: []*ec2.TagSpecification{ 273 | {ResourceType: aws.String("internet-gateway"), Tags: []*ec2.Tag{{ 274 | Key: aws.String("Name"), Value: aws.String("onkube-internet-gateway")}}}, 275 | }, 276 | } 277 | internetGateway, err := svc.CreateInternetGateway(input) 278 | if err != nil { 279 | if aerr, ok := err.(awserr.Error); ok { 280 | switch aerr.Code() { 281 | default: 282 | fmt.Println(aerr.Error()) 283 | } 284 | } else { 285 | // Print the error, cast err to awserr.Error to get the Code and 286 | // Message from an error. 287 | fmt.Println(err.Error()) 288 | } 289 | } 290 | log.Println("InternetGateway created: " + *internetGateway.InternetGateway.InternetGatewayId) 291 | return internetGateway.InternetGateway.InternetGatewayId 292 | } 293 | 294 | func createVpc(svc *ec2.EC2) *string { 295 | input := &ec2.CreateVpcInput{ 296 | CidrBlock: aws.String("10.174.0.0/16"), // Required 297 | TagSpecifications: []*ec2.TagSpecification{ 298 | {Tags: []*ec2.Tag{ 299 | {Key: aws.String("Name"), Value: aws.String("onkube-vpc")}}, 300 | ResourceType: aws.String("vpc"), 301 | }, 302 | }, 303 | } 304 | log.Println("Creating VPC...") 305 | vpc, err := svc.CreateVpc(input) 306 | if err != nil { 307 | if aerr, ok := err.(awserr.Error); ok { 308 | switch aerr.Code() { 309 | default: 310 | fmt.Println(aerr.Error()) 311 | } 312 | } else { 313 | // Print the error, cast err to awserr.Error to get the Code and 314 | // Message from an error. 315 | fmt.Println(err.Error()) 316 | } 317 | } 318 | log.Println("VPC created: ", *vpc.Vpc.VpcId) 319 | return vpc.Vpc.VpcId 320 | } 321 | 322 | // vpcId, subnetId 323 | func CreateVpcAndSubnet(svc *ec2.EC2) (*string, []string) { 324 | 325 | var vpcId *string 326 | var subnetIds []string 327 | 328 | vpcs := tools.GetVpcs(svc) 329 | if len(vpcs.Vpcs) == 0 { 330 | log.Println("No VPC found") 331 | vpcId = createVpc(svc) 332 | subnetIds = createSubnets(svc, *vpcId) 333 | } else { 334 | log.Println("VPC found, using it...") 335 | vpcId = vpcs.Vpcs[0].VpcId 336 | subnets := tools.GetSubnets(svc, vpcId) 337 | for _, subnet := range subnets { 338 | subnetIds = append(subnetIds, *subnet.SubnetId) 339 | } 340 | } 341 | return vpcId, subnetIds 342 | } 343 | 344 | func GetClient() *ec2.EC2 { 345 | sess, err := session.NewSessionWithOptions(session.Options{ 346 | SharedConfigState: session.SharedConfigEnable, 347 | Config: aws.Config{Region: aws.String(viper.GetString("aws.location"))}, 348 | }) 349 | if err != nil { 350 | log.Println(err) 351 | } 352 | return ec2.New(sess) 353 | } 354 | 355 | func GetImages() ([]*ec2.Image, error) { 356 | svc := GetClient() 357 | input := &ec2.DescribeImagesInput{ 358 | Filters: []*ec2.Filter{ 359 | { 360 | Name: aws.String("owner-alias"), 361 | Values: []*string{ 362 | aws.String("amazon"), 363 | }, 364 | }, 365 | { 366 | Name: aws.String("name"), 367 | Values: []*string{ 368 | aws.String("ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20230208"), 369 | }, 370 | }, 371 | }, 372 | } 373 | 374 | result, err := svc.DescribeImages(input) 375 | if err != nil { 376 | if aerr, ok := err.(awserr.Error); ok { 377 | switch aerr.Code() { 378 | default: 379 | fmt.Println(aerr.Error()) 380 | } 381 | } else { 382 | // Print the error, cast err to awserr.Error to get the Code and 383 | // Message from an error. 384 | fmt.Println(err.Error()) 385 | } 386 | return nil, err 387 | } 388 | return result.Images, nil 389 | } 390 | 391 | func AddSecurityGroupToInstance(svc *ec2.EC2, instanceId *string, securityGroupId *string) { 392 | instace := DescribeInstance(*instanceId) 393 | sgs := make([]*string, 0, 5) 394 | sgs = append(sgs, instace.SecurityGroups[0].GroupId) 395 | sgs = append(sgs, securityGroupId) 396 | input := &ec2.ModifyInstanceAttributeInput{ 397 | Groups: sgs, 398 | InstanceId: instanceId, 399 | } 400 | _, err := svc.ModifyInstanceAttribute(input) 401 | if err != nil { 402 | if aerr, ok := err.(awserr.Error); ok { 403 | switch aerr.Code() { 404 | default: 405 | fmt.Println(aerr.Error()) 406 | } 407 | } else { 408 | // Print the error, cast err to awserr.Error to get the Code and 409 | // Message from an error. 410 | fmt.Println(err.Error()) 411 | } 412 | } 413 | } 414 | 415 | // CreateSecurityGroupForPort creates a security group for a given port 416 | // and returns the security group id 417 | func CreateSecurityGroupForPort(svc *ec2.EC2, vpcId *string, port int64) (groupId *string) { 418 | securityGroups := GetSecurityGroups(svc, vpcId) 419 | for _, v := range securityGroups { 420 | if *v.GroupName == "onkube-sg-"+fmt.Sprint(port) { 421 | log.Println("Security Group already exists for port:", port) 422 | return v.GroupId 423 | } 424 | } 425 | 426 | input := &ec2.CreateSecurityGroupInput{ 427 | Description: aws.String("onkube security group for port " + fmt.Sprint(port)), 428 | GroupName: aws.String("onkube-sg-" + fmt.Sprint(port)), 429 | VpcId: vpcId, 430 | TagSpecifications: []*ec2.TagSpecification{ 431 | {ResourceType: aws.String("security-group"), Tags: []*ec2.Tag{{ 432 | Key: aws.String("Name"), Value: aws.String("onkube-sg-" + fmt.Sprint(port))}}}, 433 | }, 434 | } 435 | result, err := svc.CreateSecurityGroup(input) 436 | if err != nil { 437 | if aerr, ok := err.(awserr.Error); ok { 438 | switch aerr.Code() { 439 | default: 440 | fmt.Println(aerr.Error()) 441 | } 442 | } else { 443 | // Print the error, cast err to awserr.Error to get the Code and 444 | // Message from an error. 445 | fmt.Println(err.Error()) 446 | } 447 | } 448 | _, err = svc.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ 449 | GroupId: result.GroupId, 450 | IpProtocol: aws.String("tcp"), 451 | FromPort: aws.Int64(port), 452 | ToPort: aws.Int64(port), 453 | CidrIp: aws.String("0.0.0.0/0"), 454 | }) 455 | if err != nil { 456 | log.Println(err) 457 | } 458 | return result.GroupId 459 | } 460 | 461 | func DescribeInstance(instanceId string) *ec2.Instance { 462 | svc := GetClient() 463 | 464 | input := &ec2.DescribeInstancesInput{ 465 | Filters: []*ec2.Filter{ 466 | { 467 | Name: aws.String("instance-id"), 468 | Values: []*string{aws.String(instanceId)}, 469 | }, 470 | }, 471 | } 472 | instances, err := svc.DescribeInstances(input) 473 | if err != nil { 474 | if aerr, ok := err.(awserr.Error); ok { 475 | switch aerr.Code() { 476 | default: 477 | fmt.Println(aerr.Error()) 478 | } 479 | } else { 480 | // Print the error, cast err to awserr.Error to get the Code and 481 | // Message from an error. 482 | fmt.Println(err.Error()) 483 | } 484 | return nil 485 | } 486 | // log.Println(instances) 487 | return instances.Reservations[0].Instances[0] 488 | } 489 | 490 | func checkIfKeyPairExists(svc *ec2.EC2, keyName string) bool { 491 | input := &ec2.DescribeKeyPairsInput{ 492 | KeyNames: []*string{ 493 | aws.String(keyName), 494 | }, 495 | } 496 | result, err := svc.DescribeKeyPairs(input) 497 | if err != nil { 498 | if aerr, ok := err.(awserr.Error); ok { 499 | switch aerr.Code() { 500 | default: 501 | fmt.Println(aerr.Error()) 502 | } 503 | } else { 504 | // Print the error, cast err to awserr.Error to get the Code and 505 | // Message from an error. 506 | fmt.Println(err.Error()) 507 | } 508 | return false 509 | } 510 | return len(result.KeyPairs) > 0 511 | } 512 | 513 | func ImportKeyPair(svc *ec2.EC2, keyName string, publicKeyFile string) { 514 | if checkIfKeyPairExists(svc, keyName) { 515 | log.Println("Key pair already exists") 516 | return 517 | } 518 | publicKey, err := os.ReadFile(publicKeyFile) 519 | if err != nil { 520 | log.Println(err) 521 | } 522 | log.Println(publicKey) 523 | input := &ec2.ImportKeyPairInput{ 524 | KeyName: aws.String(keyName), 525 | PublicKeyMaterial: []byte(publicKey), 526 | } 527 | result, err := svc.ImportKeyPair(input) 528 | if err != nil { 529 | if aerr, ok := err.(awserr.Error); ok { 530 | switch aerr.Code() { 531 | default: 532 | fmt.Println(aerr.Error()) 533 | } 534 | } else { 535 | // Print the error, cast err to awserr.Error to get the Code and 536 | // Message from an error. 537 | fmt.Println(err.Error()) 538 | } 539 | return 540 | } 541 | log.Println(result) 542 | } 543 | 544 | func GetDefaultVpcId(svc *ec2.EC2) (vpcId *string) { 545 | input := &ec2.DescribeVpcsInput{ 546 | Filters: []*ec2.Filter{ 547 | { 548 | Name: aws.String("is-default"), 549 | Values: []*string{aws.String("true")}, 550 | }, 551 | }, 552 | } 553 | result, err := svc.DescribeVpcs(input) 554 | if err != nil { 555 | if aerr, ok := err.(awserr.Error); ok { 556 | switch aerr.Code() { 557 | default: 558 | fmt.Println(aerr.Error()) 559 | } 560 | } else { 561 | // Print the error, cast err to awserr.Error to get the Code and 562 | // Message from an error. 563 | fmt.Println(err.Error()) 564 | } 565 | return nil 566 | } 567 | return result.Vpcs[0].VpcId 568 | } 569 | 570 | func GetSecurityGroups(svc *ec2.EC2, vpcId *string) []*ec2.SecurityGroup { 571 | sgs, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ 572 | Filters: []*ec2.Filter{ 573 | { 574 | Name: aws.String("vpc-id"), 575 | Values: []*string{vpcId}, 576 | }, 577 | }, 578 | }) 579 | if err != nil { 580 | if aerr, ok := err.(awserr.Error); ok { 581 | switch aerr.Code() { 582 | default: 583 | fmt.Println(aerr.Error()) 584 | } 585 | } else { 586 | // Print the error, cast err to awserr.Error to get the Code and 587 | // Message from an error. 588 | fmt.Println(err.Error()) 589 | } 590 | } 591 | return sgs.SecurityGroups 592 | } 593 | 594 | func GetSecurityGroupByName(svc *ec2.EC2, name string) []*ec2.SecurityGroup { 595 | sgs, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ 596 | Filters: []*ec2.Filter{ 597 | { 598 | Name: aws.String("group-name"), 599 | Values: []*string{aws.String(name)}, 600 | }, 601 | }, 602 | }) 603 | if err != nil { 604 | if aerr, ok := err.(awserr.Error); ok { 605 | switch aerr.Code() { 606 | default: 607 | fmt.Println(aerr.Error()) 608 | } 609 | } else { 610 | // Print the error, cast err to awserr.Error to get the Code and 611 | // Message from an error. 612 | fmt.Println(err.Error()) 613 | } 614 | } 615 | return sgs.SecurityGroups 616 | } 617 | -------------------------------------------------------------------------------- /internal/providerazure/common.go: -------------------------------------------------------------------------------- 1 | package providerazure 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 7 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 8 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5" 9 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5" 10 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | func GetResourceGraphClient() (resourceGraphClient *armresourcegraph.Client) { 15 | cred, err := connectionAzure() 16 | if err != nil { 17 | log.Fatalf("cannot connect to Azure:%+v", err) 18 | } 19 | resourceGraphClient, err = armresourcegraph.NewClient(cred, nil) 20 | if err != nil { 21 | return nil 22 | } 23 | 24 | return resourceGraphClient 25 | } 26 | 27 | func GetVmClient() (vmClient *armcompute.VirtualMachinesClient) { 28 | cred, err := connectionAzure() 29 | if err != nil { 30 | log.Fatalf("cannot connect to Azure:%+v", err) 31 | } 32 | vmClient, err = armcompute.NewVirtualMachinesClient(viper.GetString("azure.subscriptionId"), cred, nil) 33 | // armCompute, err = armcompute. 34 | if err != nil { 35 | return nil 36 | } 37 | 38 | return vmClient 39 | } 40 | 41 | func GetNicClient() (nicClient *armnetwork.InterfacesClient) { 42 | cred, err := connectionAzure() 43 | if err != nil { 44 | log.Fatalf("cannot connect to Azure:%+v", err) 45 | } 46 | nicClient, err = armnetwork.NewInterfacesClient(viper.GetString("azure.subscriptionId"), cred, nil) 47 | if err != nil { 48 | return nil 49 | } 50 | 51 | return nicClient 52 | } 53 | 54 | func GetSSHKeyClient() (sshClient *armcompute.SSHPublicKeysClient) { 55 | cred, err := connectionAzure() 56 | if err != nil { 57 | log.Fatalf("cannot connect to Azure:%+v", err) 58 | } 59 | sshClient, err = armcompute.NewSSHPublicKeysClient(viper.GetString("azure.subscriptionId"), cred, nil) 60 | if err != nil { 61 | return nil 62 | } 63 | 64 | return sshClient 65 | } 66 | 67 | func GetIPClient() (publicIpClient *armnetwork.PublicIPAddressesClient) { 68 | cred, err := connectionAzure() 69 | if err != nil { 70 | log.Fatalf("cannot connect to Azure:%+v", err) 71 | } 72 | ipClient, err := armnetwork.NewPublicIPAddressesClient(viper.GetString("azure.subscriptionId"), cred, nil) 73 | if err != nil { 74 | return nil 75 | } 76 | 77 | return ipClient 78 | } 79 | 80 | func GetVnetClient() (vnetClient *armnetwork.VirtualNetworksClient) { 81 | cred, err := connectionAzure() 82 | if err != nil { 83 | log.Fatalf("cannot connect to Azure:%+v", err) 84 | } 85 | vnetClient, err = armnetwork.NewVirtualNetworksClient(viper.GetString("azure.subscriptionId"), cred, nil) 86 | if err != nil { 87 | return nil 88 | } 89 | 90 | return vnetClient 91 | } 92 | 93 | func connectionAzure() (azcore.TokenCredential, error) { 94 | cred, err := azidentity.NewDefaultAzureCredential(nil) 95 | if err != nil { 96 | return nil, err 97 | } 98 | return cred, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/providergcp/common.go: -------------------------------------------------------------------------------- 1 | package providergcp 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | compute "cloud.google.com/go/compute/apiv1" 8 | ) 9 | 10 | func GetClient() *compute.InstancesClient { 11 | ctx := context.Background() 12 | client, err := compute.NewInstancesRESTClient(ctx) 13 | if err != nil { 14 | log.Fatalln(err) 15 | } 16 | return client 17 | } 18 | 19 | func GetGroupClient() *compute.InstanceGroupsClient { 20 | ctx := context.Background() 21 | client, err := compute.NewInstanceGroupsRESTClient(ctx) 22 | if err != nil { 23 | log.Fatalln(err) 24 | } 25 | return client 26 | } 27 | -------------------------------------------------------------------------------- /internal/providerhtz/common.go: -------------------------------------------------------------------------------- 1 | package providerhtz 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 8 | ) 9 | 10 | func GetClient() *hcloud.Client { 11 | token := os.Getenv("HCLOUD_TOKEN") 12 | if token != "" { 13 | client := hcloud.NewClient(hcloud.WithToken(token)) 14 | return client 15 | } else { 16 | log.Println("HCLOUD_TOKEN is not set") 17 | os.Exit(1) 18 | } 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/rand/rand.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | // const charset = "abcdefghijklmnopqrstuvwxyz0123456789" 9 | const charset = "abcdefghjkmnpqrstuvwxyz23456789" //Some chars i,l,o,1,0 removed 10 | const charalphabetic = "abcdefghjkmnpqrstuvwxyz" //Only Alphabetic characters and Some chars i,l,o, removed 11 | const charsetSpecial = "abcdefghjkmnpqrstuvwxyz23456789!@#$%^&*()_+" 12 | 13 | var seededRand *rand.Rand = rand.New( 14 | rand.NewSource(time.Now().UnixNano())) 15 | 16 | // StringWithCharset .. returns random string with charset 17 | func StringWithCharset(length int, charset string) string { 18 | b := make([]byte, length) 19 | b[0] = charset[seededRand.Intn(len(charalphabetic))] 20 | for i := 1; i < length; i++ { 21 | b[i] = charset[seededRand.Intn(len(charset))] 22 | } 23 | return string(b) 24 | } 25 | 26 | // String return random string with length 27 | func String(length int) string { 28 | return StringWithCharset(length, charset) 29 | } 30 | 31 | func Password(length int) string { 32 | return StringWithCharset(length, charsetSpecial) 33 | } 34 | -------------------------------------------------------------------------------- /internal/rand/rand_test.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func Test_String(t *testing.T) { 9 | str := String(10) 10 | if len(str) != 10 { 11 | t.Errorf("%s is not 10 chars", str) 12 | } 13 | str2 := String(10) 14 | if str == str2 { 15 | t.Errorf("%s == %s", str, str2) 16 | } 17 | 18 | str = String(6) 19 | if len(str) != 6 { 20 | t.Errorf("%s is not 6 chars", str) 21 | } 22 | 23 | const numericset = "0123456789" 24 | str3 := String(7) 25 | if strings.Contains(numericset, string(str3[0])) { 26 | t.Errorf("%s is starting numeric char", str3) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/tools/cicd.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log" 5 | "os/user" 6 | "strings" 7 | 8 | "github.com/cdalar/onctl/internal/rand" 9 | ) 10 | 11 | func GenerateMachineUniqueName() string { 12 | return "onctl-" + rand.String(5) 13 | } 14 | 15 | func GenerateUserName() string { 16 | userCurrent, err := user.Current() 17 | if err != nil { 18 | log.Fatalf("%s", err.Error()) 19 | } 20 | userName := strings.ReplaceAll(userCurrent.Username, "\\", "-") 21 | userName = strings.ReplaceAll(userName, " ", "-") 22 | userName = strings.ReplaceAll(userName, "/", "-") 23 | userName = strings.ReplaceAll(userName, ".", "-") 24 | 25 | return userName 26 | 27 | } 28 | -------------------------------------------------------------------------------- /internal/tools/cloud-init.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "encoding/base64" 5 | "log" 6 | "os" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | func FileToBase64(filepath string) string { 12 | if filepath == "" { 13 | return "" 14 | } 15 | // Check if file exists 16 | if _, err := os.Stat(filepath); err != nil { 17 | log.Println("FileToBase64:" + err.Error()) 18 | log.Println("Setting empty cloud-init file") 19 | return "" 20 | } 21 | 22 | // Read the file 23 | data, err := os.ReadFile(filepath) 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | // Encode to base64 28 | encoded := base64.StdEncoding.EncodeToString(data) 29 | return encoded 30 | } 31 | 32 | // WaitForCloudInit waits for cloud-init to finish 33 | func (r *Remote) WaitForCloudInit(timeout string) { 34 | log.Println("[DEBUG] Waiting for cloud-init to finish timeout:", timeout) 35 | command := "[ -f /run/cloud-init/result.json ] && echo -n \"OK\"" 36 | 37 | // Parse the timeout string into a time.Duration 38 | duration, err := time.ParseDuration(timeout) 39 | if err != nil { 40 | log.Fatalf("Invalid timeout value: %v", err) 41 | } 42 | 43 | timer := time.After(duration) 44 | 45 | for { 46 | select { 47 | case <-timer: 48 | log.Fatalln("Exiting.. Timeout reached while waiting for cloud-init to finish on IP " + r.IPAddress + " on port " + strconv.Itoa(r.SSHPort)) 49 | return 50 | default: 51 | isOK, err := r.RemoteRun(&RemoteRunConfig{ 52 | Command: command, 53 | }) 54 | if err != nil { 55 | log.Println("[DEBUG] RemoteRun:" + err.Error()) 56 | } 57 | if err == nil && isOK == "OK" { 58 | return 59 | } 60 | time.Sleep(3 * time.Second) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/tools/common.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func Contains(slice []string, searchValue string) bool { 8 | for _, value := range slice { 9 | if value == searchValue { 10 | return true 11 | } 12 | } 13 | return false 14 | } 15 | 16 | func WhichCloudProvider() string { 17 | if os.Getenv("AWS_ACCESS_KEY_ID") != "" || os.Getenv("AWS_PROFILE") != "" { 18 | return "aws" 19 | } 20 | if os.Getenv("AZURE_CLIENT_ID") != "" { 21 | return "azure" 22 | } 23 | if os.Getenv("GOOGLE_CREDENTIALS") != "" { 24 | return "gcp" 25 | } 26 | if os.Getenv("HCLOUD_TOKEN") != "" { 27 | return "hetzner" 28 | } 29 | return "none" 30 | } 31 | -------------------------------------------------------------------------------- /internal/tools/deploy.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type DeployOutput struct { 10 | Username string `json:"username"` 11 | PublicURL string `json:"public_url"` 12 | PublicIP string `json:"public_ip"` 13 | DockerHost string `json:"docker_host"` 14 | } 15 | 16 | func CreateDeployOutputFile(deployOutput *DeployOutput) { 17 | json, err := json.MarshalIndent(deployOutput, "", " ") 18 | if err != nil { 19 | log.Println(err) 20 | } 21 | file, err := os.Create("onctl-deploy.json") 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | defer file.Close() 26 | 27 | _, err = file.Write(json) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | err = file.Sync() 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | 38 | func StringSliceToPointerSlice(strSlice []string) []*string { 39 | ptrSlice := make([]*string, len(strSlice)) 40 | for i, str := range strSlice { 41 | ptrSlice[i] = &str 42 | } 43 | return ptrSlice 44 | } 45 | -------------------------------------------------------------------------------- /internal/tools/puppet/puppet.go: -------------------------------------------------------------------------------- 1 | package puppet 2 | 3 | type Inventory struct { 4 | Groups []Group `yaml:"groups"` 5 | Config Config `yaml:"config"` 6 | } 7 | 8 | type Group struct { 9 | Name string `yaml:"name"` 10 | Targets []string `yaml:"targets"` 11 | // Config Config `yaml:"config"` 12 | } 13 | 14 | type Config struct { 15 | Transport string `yaml:"transport"` 16 | SSH SSH `yaml:"ssh"` 17 | } 18 | 19 | type SSH struct { 20 | User string `yaml:"user,omitempty"` 21 | Password string `yaml:"password,omitempty"` // Consider using SSH keys instead 22 | PrivateKey string `yaml:"private-key,omitempty"` // Uncomment if using SSH keys 23 | HostKeyCheck bool `yaml:"host-key-check"` // Optional: set to false to disable host key checking 24 | RunAs string `yaml:"run-as,omitempty"` // Optional: specify a user to escalate to 25 | NativeSSH bool `yaml:"native-ssh"` // Optional: set to true to use native ssh instead of go ssh 26 | SSHCommand string `yaml:"ssh-command,omitempty"` // Optional: specify a custom ssh command 27 | } 28 | -------------------------------------------------------------------------------- /internal/tools/remote-run.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/briandowns/spinner" 16 | "github.com/cdalar/onctl/internal/files" 17 | "golang.org/x/crypto/ssh" 18 | "golang.org/x/term" 19 | ) 20 | 21 | const ( 22 | ONCTLDIR = ".onctl" 23 | ) 24 | 25 | type Remote struct { 26 | Username string 27 | IPAddress string 28 | SSHPort int 29 | PrivateKey string 30 | Passphrase string 31 | Spinner *spinner.Spinner 32 | Client *ssh.Client 33 | } 34 | 35 | type RemoteRunConfig struct { 36 | Command string 37 | Vars []string 38 | } 39 | 40 | type CopyAndRunRemoteFileConfig struct { 41 | File string 42 | Vars []string 43 | } 44 | 45 | func (r *Remote) ReadPassphrase() (string, error) { 46 | // fmt.Println("Error: Passphrase is missing for the private key") 47 | fmt.Print("Enter passphrase for private key:") 48 | 49 | // Turn off input echoing 50 | bytePassphrase, err := term.ReadPassword(int(os.Stdin.Fd())) 51 | if err != nil { 52 | return "", err 53 | } 54 | fmt.Println() // Print a newline after the password input 55 | r.Passphrase = string(bytePassphrase) 56 | return string(bytePassphrase), nil 57 | } 58 | 59 | func (r *Remote) NewSSHConnection() error { 60 | var ( 61 | key ssh.Signer 62 | err error 63 | ) 64 | if r.Client != nil { 65 | return nil 66 | } 67 | if r.Passphrase != "" { 68 | key, err = ssh.ParsePrivateKeyWithPassphrase([]byte(r.PrivateKey), []byte(r.Passphrase)) 69 | } else { 70 | key, err = ssh.ParsePrivateKey([]byte(r.PrivateKey)) 71 | } 72 | if err != nil { 73 | if _, ok := err.(*ssh.PassphraseMissingError); ok { 74 | // fmt.Println("Error: Passphrase is missing for the private key") 75 | if r.Spinner != nil { 76 | r.Spinner.Stop() 77 | } 78 | passphrase, err := r.ReadPassphrase() 79 | if r.Spinner != nil { 80 | r.Spinner.Restart() 81 | } 82 | if err != nil { 83 | log.Fatalln("Error reading passphrase: ", err) 84 | } 85 | key, err = ssh.ParsePrivateKeyWithPassphrase([]byte(r.PrivateKey), []byte(passphrase)) 86 | if err != nil { 87 | log.Fatalln("Error parsing private key: ", err) 88 | } 89 | } else { 90 | log.Fatalln("Error parsing private key: ", err) 91 | return err 92 | } 93 | } 94 | // Authentication 95 | config := &ssh.ClientConfig{ 96 | User: r.Username, 97 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 98 | Timeout: time.Second * 7, 99 | Auth: []ssh.AuthMethod{ 100 | ssh.PublicKeys(key), 101 | }, 102 | } 103 | // Connect 104 | r.Client, err = ssh.Dial("tcp", net.JoinHostPort(r.IPAddress, fmt.Sprint(r.SSHPort)), config) 105 | if err != nil { 106 | return err 107 | } 108 | return nil 109 | } 110 | 111 | // exists returns whether the given file or directory exists 112 | func exists(path string) (bool, error) { 113 | fmt.Println("Checking if ", path, " exists") 114 | _, err := os.Stat(path) 115 | if err == nil { 116 | return true, nil 117 | } 118 | if os.IsNotExist(err) { 119 | return false, nil 120 | } 121 | return false, err 122 | } 123 | 124 | func ParseDotEnvFile(dotEnvFile string) ([]string, error) { 125 | var vars []string 126 | file, err := os.Open(dotEnvFile) 127 | if err != nil { 128 | return nil, err 129 | } 130 | defer file.Close() 131 | scanner := bufio.NewScanner(file) 132 | for scanner.Scan() { 133 | line := scanner.Text() 134 | line = strings.Trim(line, " ") 135 | if strings.HasPrefix(line, "#") || line == "" { 136 | continue 137 | } 138 | vars = append(vars, line) 139 | } 140 | return vars, nil 141 | } 142 | 143 | func variablesToEnvVars(vars []string) string { 144 | if len(vars) == 0 { 145 | return "" 146 | } 147 | 148 | var command string 149 | for _, value := range vars { 150 | envs := strings.SplitN(value, "=", 2) 151 | if len(envs) == 1 { 152 | envs = append(envs, os.Getenv(envs[0])) 153 | } 154 | vars_command := envs[0] + "=" + strconv.Quote(envs[1]) 155 | command += vars_command + " " 156 | } 157 | return command 158 | } 159 | func NextApplyDir(path string) (applyDirName string, nextApplyDirError error) { 160 | if path == "" { 161 | path = "." 162 | } 163 | if path[:1] == "/" { 164 | path = path[1:] 165 | } 166 | 167 | dir := path + "/" + ONCTLDIR 168 | ok, err := exists(dir) 169 | if err != nil { 170 | log.Fatal(err) 171 | } 172 | fmt.Println(ok) 173 | fmt.Println(dir) 174 | // Check if .onctl dir exists 175 | if ok, err := exists(dir); err != nil { 176 | log.Fatal(err) 177 | } else if !ok { // .onctl dir does not exist 178 | // Create .onctl dir 179 | fmt.Println("Creating .onctl dir") 180 | err := os.Mkdir(dir, 0755) 181 | if err != nil { 182 | log.Fatal(err) 183 | } 184 | // Create apply dir 185 | applyDirName = dir + "/apply00" 186 | err = os.Mkdir(applyDirName, 0755) 187 | if err != nil { 188 | log.Fatal(err) 189 | } 190 | return applyDirName, nil 191 | } else if ok { // .onctl dir exists 192 | fmt.Println("onctl exists; Checking apply dir") 193 | files, err := os.ReadDir(dir) 194 | if err != nil { 195 | log.Fatal(err) 196 | } 197 | maxNum := -1 198 | for _, f := range files { 199 | fmt.Println(f.Name()) 200 | // Extract the number from the directory name 201 | dirName := f.Name() 202 | numStr := strings.TrimPrefix(dirName, "apply") 203 | fmt.Println(numStr) 204 | num, err := strconv.Atoi(numStr) 205 | if err == nil && num > maxNum { 206 | maxNum = num 207 | } 208 | } 209 | applyDirName = path + "/" + ONCTLDIR + "/apply" + fmt.Sprintf("%02d", maxNum+1) 210 | // Check if apply dir exists 211 | if okApply, err := exists(applyDirName); err != nil { 212 | log.Fatal(err) 213 | } else if !okApply { // apply dir does not exist 214 | // Create apply dir 215 | fmt.Println(maxNum) 216 | err = os.Mkdir(applyDirName, 0755) 217 | if err != nil { 218 | log.Fatal(err) 219 | 220 | } 221 | return applyDirName, nil 222 | } 223 | } 224 | return "", nil 225 | } 226 | 227 | func (r *Remote) RemoteRun(remoteRunConfig *RemoteRunConfig) (string, error) { 228 | log.Println("[DEBUG] remoteRunConfig: ", remoteRunConfig) 229 | // Create a new SSH connection 230 | err := r.NewSSHConnection() 231 | if err != nil { 232 | return "", err 233 | } 234 | 235 | // Create a session. It is one session per command. 236 | session, err := r.Client.NewSession() 237 | if err != nil { 238 | return "", err 239 | } 240 | defer session.Close() 241 | stdOutReader, err := session.StdoutPipe() 242 | if err != nil { 243 | return "", err 244 | } 245 | 246 | // Set env vars 247 | if len(remoteRunConfig.Vars) > 0 { 248 | remoteRunConfig.Command = variablesToEnvVars(remoteRunConfig.Vars) + " && " + remoteRunConfig.Command 249 | } 250 | err = session.Run(remoteRunConfig.Command) 251 | buf := make([]byte, 1024) 252 | var returnString string 253 | for { 254 | n, err := stdOutReader.Read(buf) 255 | if err == io.EOF { 256 | break 257 | } 258 | if err != nil { 259 | fmt.Println(err) 260 | continue 261 | } 262 | if n > 0 { 263 | log.Println("[DEBUG] " + string(buf[:n])) 264 | // fmt.Print(string(buf[:n])) 265 | returnString += string(buf[:n]) 266 | } 267 | } 268 | return returnString, err 269 | } 270 | 271 | // creates a new apply dir and copies the file to the remote ex. ~/.onctl/apply01 272 | // executes the file on the remote 273 | func (r *Remote) CopyAndRunRemoteFile(config *CopyAndRunRemoteFileConfig) error { 274 | log.Println("[DEBUG] CopyAndRunRemoteFile: ", config.File) 275 | fileBaseName := filepath.Base(config.File) 276 | fileContent, err := files.EmbededFiles.ReadFile("apply_dir.sh") 277 | if err != nil { 278 | log.Fatalln(err) 279 | } 280 | command := string(fileContent) 281 | nextApplyDir, err := r.RemoteRun(&RemoteRunConfig{ 282 | Command: command, 283 | Vars: []string{"ONCTLDIR=" + ONCTLDIR}, 284 | }) 285 | log.Println("[DEBUG] nextApplyDir: ", nextApplyDir) 286 | if err != nil { 287 | fmt.Println(nextApplyDir) 288 | log.Fatalln(err) 289 | } 290 | dstPath := ONCTLDIR + "/" + nextApplyDir + "/" + fileBaseName 291 | log.Println("[DEBUG] dstPath:", dstPath) 292 | err = r.SSHCopyFile(config.File, dstPath) 293 | if err != nil { 294 | log.Println("RemoteRun error: ", err) 295 | return err 296 | } 297 | 298 | config.Vars = append(config.Vars, "PUBLIC_IP="+r.IPAddress) 299 | command = "cd " + ONCTLDIR + "/" + nextApplyDir + " && chmod +x " + fileBaseName + " && if [[ -f .env ]]; then set -o allexport; source .env; set +o allexport; fi && " + variablesToEnvVars(config.Vars) + "sudo -E ./" + fileBaseName + "> output-" + fileBaseName + ".log 2>&1" 300 | 301 | log.Println("[DEBUG] command: ", command) 302 | _, err = r.RemoteRun(&RemoteRunConfig{ 303 | Command: command, 304 | }) 305 | if err != nil { 306 | return err 307 | } 308 | return nil 309 | } 310 | -------------------------------------------------------------------------------- /internal/tools/remote-run_test.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestVariablesToEnvVars(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | vars []string 11 | expected string 12 | }{ 13 | { 14 | name: "Empty input", 15 | vars: []string{}, 16 | expected: "", 17 | }, 18 | { 19 | name: "Single variable", 20 | vars: []string{"KEY=value"}, 21 | expected: "KEY=\"value\" ", 22 | }, 23 | { 24 | name: "Multiple variables", 25 | vars: []string{"KEY1=value1", "KEY2=value2"}, 26 | expected: "KEY1=\"value1\" KEY2=\"value2\" ", 27 | }, 28 | { 29 | name: "Variable with spaces", 30 | vars: []string{"KEY=value with spaces"}, 31 | expected: "KEY=\"value with spaces\" ", 32 | }, 33 | } 34 | 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | result := variablesToEnvVars(tt.vars) 38 | if result != tt.expected { 39 | t.Errorf("expected %q, got %q", tt.expected, result) 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/tools/scp.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/pkg/sftp" 8 | ) 9 | 10 | func (r *Remote) DownloadFile(srcPath, dstPath string) error { 11 | // Create a new SSH connection 12 | err := r.NewSSHConnection() 13 | if err != nil { 14 | return err 15 | } 16 | 17 | // open an SFTP session over an existing ssh connection. 18 | sftp, err := sftp.NewClient(r.Client) 19 | if err != nil { 20 | return err 21 | } 22 | defer sftp.Close() 23 | 24 | // Open the source file 25 | srcFile, err := sftp.Open(srcPath) 26 | if err != nil { 27 | return err 28 | } 29 | defer srcFile.Close() 30 | 31 | // Create the destination file 32 | dstFile, err := os.Create(dstPath) 33 | if err != nil { 34 | return err 35 | } 36 | defer dstFile.Close() 37 | 38 | // write to file 39 | if _, err := srcFile.WriteTo(dstFile); err != nil { 40 | return err 41 | } 42 | return nil 43 | } 44 | 45 | func (r *Remote) SSHCopyFile(srcPath, dstPath string) error { 46 | log.Println("[DEBUG] srcPath:" + srcPath) 47 | log.Println("[DEBUG] dstPath:" + dstPath) 48 | 49 | // Create a new SSH connection 50 | err := r.NewSSHConnection() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // open an SFTP session over an existing ssh connection. 56 | sftp, err := sftp.NewClient(r.Client) 57 | if err != nil { 58 | return err 59 | } 60 | defer sftp.Close() 61 | 62 | // Open the source file 63 | srcFile, err := os.Open(srcPath) 64 | if err != nil { 65 | log.Println(err) 66 | return err 67 | } 68 | defer srcFile.Close() 69 | 70 | // Create the destination file 71 | dstFile, err := sftp.Create(dstPath) 72 | if err != nil { 73 | return err 74 | } 75 | defer dstFile.Close() 76 | 77 | // write to file 78 | if _, err := dstFile.ReadFrom(srcFile); err != nil { 79 | return err 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/tools/ssh.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | type SSHIntoVMRequest struct { 12 | IPAddress string 13 | User string 14 | Port int 15 | PrivateKeyFile string 16 | } 17 | 18 | func SSHIntoVM(request SSHIntoVMRequest) { 19 | sshArgs := []string{ 20 | "-o", "UserKnownHostsFile=/dev/null", 21 | "-o", "StrictHostKeyChecking=no", 22 | "-i", request.PrivateKeyFile, 23 | "-l", request.User, 24 | request.IPAddress, 25 | "-p", fmt.Sprint(request.Port), 26 | } 27 | log.Println("[DEBUG] sshArgs: ", sshArgs) 28 | // sshCommand := exec.Command("ssh", append(sshArgs, args[1:]...)...) 29 | sshCommand := exec.Command("ssh", sshArgs...) 30 | sshCommand.Stdin = os.Stdin 31 | sshCommand.Stdout = os.Stdout 32 | sshCommand.Stderr = os.Stderr 33 | 34 | if err := sshCommand.Run(); err != nil { 35 | if exitError, ok := err.(*exec.ExitError); ok { 36 | waitStatus := exitError.Sys().(syscall.WaitStatus) 37 | os.Exit(waitStatus.ExitStatus()) 38 | } else { 39 | log.Panic(err) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/tools/subnets.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/awserr" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | ) 10 | 11 | func GetSubnets(svc *ec2.EC2, vpcId *string) []*ec2.Subnet { 12 | subnets, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{ 13 | Filters: []*ec2.Filter{ 14 | {Name: aws.String("vpc-id"), 15 | Values: []*string{vpcId}, 16 | }, 17 | }, 18 | }) 19 | if err != nil { 20 | if aerr, ok := err.(awserr.Error); ok { 21 | switch aerr.Code() { 22 | default: 23 | fmt.Println(aerr.Error()) 24 | } 25 | } else { 26 | // Print the error, cast err to awserr.Error to get the Code and 27 | // Message from an error. 28 | fmt.Println(err.Error()) 29 | } 30 | } 31 | return subnets.Subnets 32 | 33 | } 34 | -------------------------------------------------------------------------------- /internal/tools/vpcs.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/awserr" 8 | "github.com/aws/aws-sdk-go/service/ec2" 9 | ) 10 | 11 | func GetVpcs(svc *ec2.EC2) *ec2.DescribeVpcsOutput { 12 | vpcs, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{ 13 | Filters: []*ec2.Filter{ 14 | {Name: aws.String("tag:Name"), Values: []*string{aws.String("onkube-vpc")}}}, 15 | }) 16 | if err != nil { 17 | if aerr, ok := err.(awserr.Error); ok { 18 | switch aerr.Code() { 19 | default: 20 | fmt.Println(aerr.Error()) 21 | } 22 | } else { 23 | // Print the error, cast err to awserr.Error to get the Code and 24 | // Message from an error. 25 | fmt.Println(err.Error()) 26 | } 27 | } 28 | return vpcs 29 | } 30 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/cdalar/onctl/cmd" 8 | 9 | "github.com/hashicorp/logutils" 10 | ) 11 | 12 | func main() { 13 | filter := &logutils.LevelFilter{ 14 | Levels: []logutils.LogLevel{"DEBUG", "WARN", "ERROR"}, 15 | MinLevel: logutils.LogLevel("WARN"), 16 | Writer: os.Stderr, 17 | } 18 | if os.Getenv("ONCTL_LOG") != "" { 19 | filter.MinLevel = logutils.LogLevel(os.Getenv("ONCTL_LOG")) 20 | log.SetFlags(log.Lshortfile) 21 | } 22 | log.SetOutput(filter) 23 | err := cmd.Execute() 24 | if err != nil { 25 | log.Println(err) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /test-compose.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DOCKER_HOST=ssh://root@65.21.48.1 docker compose -f internal/files/docker-compose.yml up -d --build 3 | -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Build for Linux 3 | GOOS=linux GOARCH=amd64 go build -o onctl-linux -ldflags="-w -s -X 'github.com/cdalar/onctl/cmd.Version=$(git rev-parse HEAD | cut -c1-7)'" 4 | GOOS=windows GOARCH=amd64 go build -o onctl.exe -ldflags="-w -s -X 'github.com/cdalar/onctl/cmd.Version=$(git rev-parse HEAD | cut -c1-7)'" 5 | # GOOS=darwin GOARCH=amd64 go build -o onctl-mac -ldflags="-w -s -X 'github.com/cdalar/onctl/cmd.Version=$(git rev-parse HEAD | cut -c1-7)'" 6 | gh release delete-asset v0.1.0 onctl.exe -y 7 | gh release upload v0.1.0 onctl.exe 8 | gh release delete-asset v0.1.0 onctl-linux -y 9 | gh release upload v0.1.0 onctl-linux 10 | 11 | --------------------------------------------------------------------------------