diff --git a/go.work b/go.work index bece8b7cf..981dea955 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.21 +go 1.21.0 use ( ./activation-service @@ -12,4 +12,5 @@ use ( ./tfrobot ./tools/relay-cache-warmer ./user-contracts-mon + ./grid-compose ) diff --git a/grid-compose/.gitignore b/grid-compose/.gitignore new file mode 100644 index 000000000..4bb6ca521 --- /dev/null +++ b/grid-compose/.gitignore @@ -0,0 +1,6 @@ +bin/* +.pre-commit-config.yaml +out +full_example.yml +invalid +grid-compose.yml diff --git a/grid-compose/Makefile b/grid-compose/Makefile new file mode 100644 index 000000000..5704400b7 --- /dev/null +++ b/grid-compose/Makefile @@ -0,0 +1,21 @@ +test: + @echo "Running Tests" + go test -v ./... + +clean: + rm ./bin -rf + +getverifiers: + @echo "Installing golangci-lint" && go install github.com/golangci/golangci-lint/cmd/golangci-lint + go mod tidy + +lint: + @echo "Running $@" + golangci-lint run -c ../.golangci.yml + +build: + @echo "Running $@" + @go build -ldflags=\ + "-X 'github.com/threefoldtech/tfgrid-sdk-go/grid-compose/cmd.commit=$(shell git rev-parse HEAD)'\ + -X 'github.com/threefoldtech/tfgrid-sdk-go/grid-compose/cmd.version=$(shell git tag --sort=-version:refname | head -n 1)'"\ + -o bin/grid-compose main.go diff --git a/grid-compose/README.md b/grid-compose/README.md new file mode 100644 index 000000000..9299a023b --- /dev/null +++ b/grid-compose/README.md @@ -0,0 +1,163 @@ +# Grid-Compose + +is a tool similar to docker-compose created for running multi-vm applications on TFGrid defined using a Yaml formatted file. + +The yaml file's structure is defined in [docs/config](docs/config.md). + +## Usage + +`REQUIRED` EnvVars: + +- `MNEMONIC`: your secret words +- `NETWORK`: one of (dev, qa, test, main) + +```bash +grid-compose [OPTIONS] [COMMAND] + +OPTIONS: + -f, --file: path to yaml file, default is ./grid-compose.yml + +COMMANDS: + - version: shows the project version + - up: deploy the app + - down: cancel all deployments + - ps: list deployments on the grid + OPTIONS: + - -v, --verbose: show full details of each deployment +``` + +Export env vars using: + +```bash +export MNEMONIC=your_mnemonics +export NETWORK=working_network +``` + +Run: + +```bash +make build +``` + +To use any of the commands, run: + +```bash +./bin/grid-compose [COMMAND] +``` + +For example: + +```bash +./bin/grid-compose ps -f example/multiple_services_diff_network_3.yml +``` + +## Usage For Each Command + +### up + +The up command deploys the services defined in the yaml file to the grid. + +Refer to the [cases](docs/cases.md) for more information on the cases supported. + +Refer to examples in the [examples](examples) directory to have a look at different possible configurations. + +```bash +./bin/grid-compose up [OPTIONS] +``` + +OPTIONS: + +- `-f, --file`: path to the yaml file, default is `./grid-compose.yml` + +### Example + +```bash +./bin/grid-compose up +``` + +output: + +```bash +3:40AM INF starting peer session=tf-848216 twin=8658 +3:40AM INF deploying network... name=miaminet node_id=14 +3:41AM INF deployed successfully +3:41AM INF deploying vm... name=database node_id=14 +3:41AM INF deployed successfully +3:41AM INF deploying network... name=miaminet node_id=14 +3:41AM INF deployed successfully +3:41AM INF deploying vm... name=server node_id=14 +3:41AM INF deployed successfully +3:41AM INF all deployments deployed successfully +``` + +### down + +The down command cancels all deployments on the grid. + +```bash +./bin/grid-compose down [OPTIONS] +``` + +OPTIONS: + +- `-f, --file`: path to the yaml file, default is `./grid-compose.yml` + +### Example + +```bash +./bin/grid-compose down +``` + +output: + +```bash +3:45AM INF starting peer session=tf-854215 twin=8658 +3:45AM INF canceling deployments projectName=vm/compose/8658/net1 +3:45AM INF canceling contracts project name=vm/compose/8658/net1 +3:45AM INF project is canceled project name=vm/compose/8658/net1 +``` + +### ps + +The ps command lists all deployments on the grid. + +```bash +./bin/grid-compose ps [FLAGS] [OPTIONS] +``` + +OPTIONS: + +- `-f, --file`: path to the yaml file, default is `./grid-compose.yml` + +### Example + +```bash +./bin/grid-compose ps +``` + +output: + +```bash +3:43AM INF starting peer session=tf-851312 twin=8658 + +Deployment Name | Node ID | Network | Services | Storage | State | IP Address +------------------------------------------------------------------------------------------------------------------------------------------------------ +dl_database | 14 | miaminet | database | dbdata | ok | wireguard: 10.20.2.2 +dl_server | 14 | miaminet | server | webdata | ok | wireguard: 10.20.2.3 +``` + +FLAGS: + +- `-v, --verbose`: show full details of each deployment + +### version + +The version command shows the project's current version. + +```bash +./bin/grid-compose version +``` + +## Future Work + +Refer to [docs/future_work.md](docs/future_work.md) for more information on the future work that is to be done on the grid-compose project. diff --git a/grid-compose/cmd/down.go b/grid-compose/cmd/down.go new file mode 100644 index 000000000..2c7897c43 --- /dev/null +++ b/grid-compose/cmd/down.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app" +) + +var downCmd = &cobra.Command{ + Use: "down", + Short: "cancel your project on the grid", + Run: func(cmd *cobra.Command, args []string) { + app, ok := cmd.Context().Value("app").(*app.App) + if !ok { + log.Fatal().Msg("app not found in context") + } + + if err := app.Down(); err != nil { + log.Fatal().Err(err).Send() + } + }, +} diff --git a/grid-compose/cmd/ps.go b/grid-compose/cmd/ps.go new file mode 100644 index 000000000..c61a7d46d --- /dev/null +++ b/grid-compose/cmd/ps.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app" +) + +var psCmd = &cobra.Command{ + Use: "ps", + Short: "list deployments on the grid", + Run: func(cmd *cobra.Command, args []string) { + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + log.Fatal().Err(err).Send() + } + + app, ok := cmd.Context().Value("app").(*app.App) + if !ok { + log.Fatal().Msg("app not found in context") + } + + if err := app.Ps(cmd.Context(), verbose); err != nil { + log.Fatal().Err(err).Send() + } + }, +} diff --git a/grid-compose/cmd/root.go b/grid-compose/cmd/root.go new file mode 100644 index 000000000..cc5897cf9 --- /dev/null +++ b/grid-compose/cmd/root.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "os" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app" +) + +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatal().Err(err).Send() + } +} + +// TODO: validate command line arguments +var rootCmd = &cobra.Command{ + Use: "grid-compose", + Short: "Grid-Compose is a tool for running multi-vm applications on TFGrid defined using a Yaml formatted file.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + network := os.Getenv("NETWORK") + mnemonic := os.Getenv("MNEMONIC") + configPath, _ := cmd.Flags().GetString("file") + + app, err := app.NewApp(network, mnemonic, configPath) + + if err != nil { + log.Fatal().Err(err).Send() + } + + ctx := context.WithValue(cmd.Context(), "app", app) + cmd.SetContext(ctx) + }, +} + +func init() { + rootCmd.PersistentFlags().StringP("file", "f", "./grid-compose.yml", "the grid-compose configuration file") + + rootCmd.AddCommand(downCmd) + rootCmd.AddCommand(upCmd) + rootCmd.AddCommand(versionCmd) + + psCmd.PersistentFlags().BoolP("verbose", "v", false, "all information about deployed services") + rootCmd.AddCommand(psCmd) + + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) +} diff --git a/grid-compose/cmd/up.go b/grid-compose/cmd/up.go new file mode 100644 index 000000000..bc8c305d4 --- /dev/null +++ b/grid-compose/cmd/up.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app" +) + +var upCmd = &cobra.Command{ + Use: "up", + Short: "deploy application on the grid", + Run: func(cmd *cobra.Command, args []string) { + app, ok := cmd.Context().Value("app").(*app.App) + if !ok { + log.Fatal().Msg("app not found in context") + } + + if err := app.Up(cmd.Context()); err != nil { + log.Fatal().Err(err).Send() + } + }, +} diff --git a/grid-compose/cmd/version.go b/grid-compose/cmd/version.go new file mode 100644 index 000000000..ebe132cf0 --- /dev/null +++ b/grid-compose/cmd/version.go @@ -0,0 +1,22 @@ +// Package cmd for parsing command line arguments +package cmd + +import ( + "log" + + "github.com/spf13/cobra" +) + +// set at build time +var commit string +var version string + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Get latest build tag", + Run: func(cmd *cobra.Command, args []string) { + log.Println(version) + log.Println(commit) + }, +} diff --git a/grid-compose/docs/cases.md b/grid-compose/docs/cases.md new file mode 100644 index 000000000..7e939c55f --- /dev/null +++ b/grid-compose/docs/cases.md @@ -0,0 +1,81 @@ +These are most if not all the cases supported by the grid compose tool when deploying one or more services on the grid. + +## Single Service + +### Case 1 - Node ID Not Given + No Assigned Network + +This is probably the simplest case there is. + +- Filter the nodes based on the resources given to the service and choose a random one. +- Generate a default network and assign it to the deployment. + +Refer to example [single_service_1.yml](/examples/single-service/single_service_1.yml) + +### Case 2 - Node ID Given + No Assigned Network + +- Simply use the the node id given to deploy the service. + - Return an error if node is not available +- Generate a default network and assign it to the deployment. + +Refer to example [single_service_2.yml](/examples/single-service/single_service_2.yml) + +### Case 3 - Assigned Network + +- Either use the assigned node id or filter the nodes for an available node if no node id given. +- Use the network assigned to the service when deploying. + +Refer to example [single_service_3.yml](/examples/single-service/single_service_3.yml) + +## Multiple Services + +Dealing with multiple services will depend on the networks assigned to each service. In a nutshell, it is assumed that **if two services are assigned the same network they are going to be in the same deployment, which means in the same node,** so failing to stick to this assumption will yield errors and no service will be deployed. + +### Same Network/No Network + +This is a common general case: Laying down services a user needs to deploy in the same node using a defined network or not defining any network at all. + +#### Case 1 - Node ID Given + +Essentially what is required is that at least one service is assigned a node id and automatically all the other services assigned to the same network will be deployed using this node. + +It is also possible to assign a node id to some of the services or even all of them, but keep in mind that **if two services running on the same network and each one is assigned a different node id, this will cause an error and nothing will be deployed.** + +#### Case 2 - No Node ID Given + +This is a more common case, the user mostly probably will not care to provide any node ids. In that case: + +- The node id will be filtered based on the total resources for the services provided. + +
+If all the services are assigned a network, then all of them will be deployed using that network. + +If no networks are defined, then all the services will use the **default generated network**. + +Refer to examples + +- [two_services_same_network_1.yml](/examples/multiple-services/two_services_same_network_1.yml) +- [two_services_same_network_2.yml](/examples/multiple-services/two_services_same_network_2.yml) +- [two_services_same_network_3.yml](/examples/multiple-services/two_services_same_network_3.yml) + +### Different Networks + +Simple divide the services into groups having the same network(given or generated) and deal with each group using the approached described in the previous [section](#same-networkno-network). + +Refer to examples + +- [multiple_services_diff_network_1.yml](/examples/multiple-services/multiple_services_diff_network_1.yml) +- [multiple_services_diff_network_2.yml](/examples/multiple-services/multiple_services_diff_network_2.yml) +- [multiple_services_diff_network_3.yml](/examples/multiple-services/multiple_services_diff_network_3.yml) + +## Dependencies + +The tool supports deploying services that depend on each other. You can define dependencies in the yaml file by using the `depends_on` key, just like in docker-compose. + +Refer to examples: + +- deploying services that depend on each other on different networks: + - [diff_networks.yml](/examples/dependency/diff_networks.yml) +- deploying services that depend on each other on the same network: + - [same_network.yml](/examples/dependency/same_network.yml) +- a service that would depend on multiple services: + - [multiple_dependencies.yml](/examples/dependency/multiple_dependencies.yml) diff --git a/grid-compose/docs/config.md b/grid-compose/docs/config.md new file mode 100644 index 000000000..f53533e00 --- /dev/null +++ b/grid-compose/docs/config.md @@ -0,0 +1,119 @@ +## Configuration File + +This document describes the configuration file for the grid-compose project. + +```yaml +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + resources: + cpu: 2 + memory: 2048 + rootfs: 2048 + entrypoint: '/sbin/zinit init' + ip_types: + - ipv4 + environment: + - SSH_KEY= + node_id: 11 + healthcheck: + test: + interval: '10s' + timeout: '1m30s' + retries: 3 + volumes: + - webdata + - dbdata + network: net1 + depends_on: + - database + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + resources: + cpu: 2 + memory: 2048 + rootfs: 2048 + entrypoint: '/sbin/zinit init' + ip_types: + - ipv4 + environment: + - SSH_KEY= + network: net1 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB +``` + +The configuration file is a YAML file that contains the following sections: + +- `version`: The version of the configuration file. +- `networks`: A list of networks that the services can use `optional`. + - By default, the tool will create a network that will use to deploy each service. +- `services`: A list of services to deploy. +- `volumes`: A list of volumes that the services can use `optional`. + +### Networks + +The `networks` section defines the networks that the services can use. Each network has the following properties: + +- `name`: The name of the network. +- `range`: The IP range of the network. + - `ip`: The IP address of the network. + - `type`: The type of the IP address. + - `ip`: The IP address. + - `mask`: The subnet mask of the network. + - `type`: The type of the subnet mask. + - `mask`: The subnet mask. +- `wg`: A boolean value that indicates whether to add WireGuard access to the network. + +### Services + +The `services` section defines the services to deploy. Each service has the following properties: + +- `flist`: The URL of the flist to deploy. +- `resources`: The resources required by the service (CPU, memory, and rootfs) `optional`. + - By default, the tool will use the minimum resources required to deploy the service. + - `cpu`: 1 + - `memory`: 256MB + - `rootfs`: 2GB +- `entrypoint`: The entrypoint command to run when the service starts. +- `ip_types`: The types of IP addresses to assign to the service `optional`. + - ip type can be ipv4, ipv6, mycelium, yggdrasil. +- `environment`: The environment variables to set in the virtual machine. +- `node_id`: The ID of the node to deploy the service on `optional`. + - By default, the tool will filter the nodes based on the resources required by the service. +- `healthcheck`: The healthcheck configuration for the service `optional`. + - `test`: The command/script to run to test if the service is deployed as expected. + - `interval`: The interval between health checks. + - `timeout`: The timeout for the health check(includes the time the vm takes until it is up and ready to be connected to). + - `retries`: The number of retries for the health check. +- `volumes`: The volumes to mount in the service `optional`. +- `network`: The network to deploy the service on `optional`. + - By default, the tool will use the general network created automatically. +- `depends_on`: The services that this service depends on `optional`. + +### Volumes + +The `volumes` section defines the volumes that the services can use. Each volume has the following properties: + +- `mountpoint`: The mountpoint of the volume. +- `size`: The size of the volume. diff --git a/grid-compose/docs/future_work.md b/grid-compose/docs/future_work.md new file mode 100644 index 000000000..0fa11e885 --- /dev/null +++ b/grid-compose/docs/future_work.md @@ -0,0 +1,6 @@ +This document outlines the future work that is to be done on the grid-compose project. + +## Future Work + +1. Add path for ssh keys instead of hardcoding it in the config file. +2. Ensure backward compatibility with versions of grid-compose. diff --git a/grid-compose/examples/dependency/diff_networks.yml b/grid-compose/examples/dependency/diff_networks.yml new file mode 100644 index 000000000..74fda684e --- /dev/null +++ b/grid-compose/examples/dependency/diff_networks.yml @@ -0,0 +1,71 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + net2: + name: 'cartoonnetwork' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ipv4 + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + depends_on: + - frontend + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + node_id: 14 + depends_on: + - database + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + node_id: 14 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/dependency/multiple_dependencies.yml b/grid-compose/examples/dependency/multiple_dependencies.yml new file mode 100644 index 000000000..20487c26f --- /dev/null +++ b/grid-compose/examples/dependency/multiple_dependencies.yml @@ -0,0 +1,59 @@ +version: '1.0.0' + +networks: + net2: + name: 'cartoonnetwork' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + depends_on: + - database + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + node_id: 144 + depends_on: + - server + - database + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/dependency/same_network.yml b/grid-compose/examples/dependency/same_network.yml new file mode 100644 index 000000000..5c3622992 --- /dev/null +++ b/grid-compose/examples/dependency/same_network.yml @@ -0,0 +1,53 @@ +version: '1.0.0' + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ipv4 + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + depends_on: + - database + environment: + - SSH_KEY=ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCmxUAdlMvIIDRplCLAufygUBPN3VKPIkl+ogxKzRLl00Y1j/mIm86M//axY1cH3NNCxap+8hZ2aMR5fnoKGMERWVUxC1tYuV0CBiSU7dMHWV66f58W2Cf+La6CE3aXd97Uoodku4k6k7ENuRd/+oBjRHM3qfhZ95Rhsb74vVe8PPobQuU/3JkoXQ8yf5y73yh8hirqipBBonAQ4CQiz4qGyj8e1Wj5nzezvYj/ioPLdXwOOql9gISb9iNwqj2qVfSnv8ksEBLCNeNSzUd3TRyVcOuIuCdUEYx5EnyQntZNDb8IyGZUNvqWerz8S1bMQePzCNj7xUrQHbR8+7bbh7TEVa0UHOBK3dBeFYh0UReRWRwKnXC/2IPX1MeHjYGTSbgXP5Wxn7H+AxLGNPnezMFZRpqrs8ULc788RUfvkGX259xC/YboPrIVvY/R/cQeTEdmtq+xmC2XRR1icon/dCeDpAymW0IRMb8+VCKyNLumDhMwiHIVTLbJrg+TtqGzg+43BuxGhW7eS5FP5lK2R+7WS4uu/Eg5LGHVgv7WiyfE/SrMzmf+LPejQuQJVPIRrfps+b4hHtn7+w6jF6QxZMv7VVib9cJvpd9+QWvYqWeRf+ojXWvlBgYZHa18LO/pauylsWbVoQgEmGnDUHMP8WtlMJ6wv7YuSutgApqN+xgh+w== pluto + healthcheck: + test: ['mkdir', 'test'] + interval: '10s' + timeout: '1m30s' + retries: 3 + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + node_id: 14 + depends_on: + - server + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/multiple_services_diff_network_1.yml b/grid-compose/examples/multiple-services/multiple_services_diff_network_1.yml new file mode 100644 index 000000000..4f19d6a97 --- /dev/null +++ b/grid-compose/examples/multiple-services/multiple_services_diff_network_1.yml @@ -0,0 +1,65 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + net2: + name: 'cartoonnetwork' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + node_id: 144 + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/multiple_services_diff_network_2.yml b/grid-compose/examples/multiple-services/multiple_services_diff_network_2.yml new file mode 100644 index 000000000..f87bc0571 --- /dev/null +++ b/grid-compose/examples/multiple-services/multiple_services_diff_network_2.yml @@ -0,0 +1,74 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + net2: + name: 'cartoonnetwork' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + node_id: 144 + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + server2: + node_id: 11 + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/multiple_services_diff_network_3.yml b/grid-compose/examples/multiple-services/multiple_services_diff_network_3.yml new file mode 100644 index 000000000..e8a1c8d8d --- /dev/null +++ b/grid-compose/examples/multiple-services/multiple_services_diff_network_3.yml @@ -0,0 +1,74 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + net2: + name: 'cartoonnetwork' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + server2: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + frontend: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net2 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/two_services_same_network_1.yml b/grid-compose/examples/multiple-services/two_services_same_network_1.yml new file mode 100644 index 000000000..be885efa9 --- /dev/null +++ b/grid-compose/examples/multiple-services/two_services_same_network_1.yml @@ -0,0 +1,46 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + wg: true + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - webdata + node_id: 14 + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/two_services_same_network_2.yml b/grid-compose/examples/multiple-services/two_services_same_network_2.yml new file mode 100644 index 000000000..606516db9 --- /dev/null +++ b/grid-compose/examples/multiple-services/two_services_same_network_2.yml @@ -0,0 +1,33 @@ +version: '1.0.0' + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ygg + entrypoint: '/sbin/zinit init' + volumes: + - webdata + node_id: 144 + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/multiple-services/two_services_same_network_3.yml b/grid-compose/examples/multiple-services/two_services_same_network_3.yml new file mode 100644 index 000000000..093538697 --- /dev/null +++ b/grid-compose/examples/multiple-services/two_services_same_network_3.yml @@ -0,0 +1,32 @@ +version: '1.0.0' + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + volumes: + - webdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + + database: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + ip_types: + - ipv4 + entrypoint: '/sbin/zinit init' + volumes: + - dbdata + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + +volumes: + webdata: + mountpoint: '/data' + size: 10GB + dbdata: + mountpoint: '/var/lib/postgresql/data' + size: 10GB diff --git a/grid-compose/examples/single-service/single_service_1.yml b/grid-compose/examples/single-service/single_service_1.yml new file mode 100644 index 000000000..361b7c7c3 --- /dev/null +++ b/grid-compose/examples/single-service/single_service_1.yml @@ -0,0 +1,10 @@ +version: '1.0.0' + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 diff --git a/grid-compose/examples/single-service/single_service_2.yml b/grid-compose/examples/single-service/single_service_2.yml new file mode 100644 index 000000000..f44ffa5ef --- /dev/null +++ b/grid-compose/examples/single-service/single_service_2.yml @@ -0,0 +1,11 @@ +version: '1.0.0' + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + node_id: 144 diff --git a/grid-compose/examples/single-service/single_service_3.yml b/grid-compose/examples/single-service/single_service_3.yml new file mode 100644 index 000000000..4ffe76969 --- /dev/null +++ b/grid-compose/examples/single-service/single_service_3.yml @@ -0,0 +1,24 @@ +version: '1.0.0' + +networks: + net1: + name: 'miaminet' + range: + ip: + type: ipv4 + ip: 10.20.0.0 + mask: + type: cidr + mask: 16/32 + +services: + server: + flist: 'https://hub.grid.tf/tf-official-apps/threefoldtech-ubuntu-22.04.flist' + entrypoint: '/sbin/zinit init' + resources: + cpu: 1 + memory: 2048 + rootfs: 2048 + network: net1 + ip_types: + - myc diff --git a/grid-compose/go.mod b/grid-compose/go.mod new file mode 100644 index 000000000..dcfa82eeb --- /dev/null +++ b/grid-compose/go.mod @@ -0,0 +1,21 @@ +module github.com/threefoldtech/tfgrid-sdk-go/grid-compose + +go 1.21.0 + +require ( + github.com/rs/zerolog v1.33.0 + github.com/spf13/cobra v1.8.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/melbahja/goph v1.4.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.5 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/grid-compose/go.sum b/grid-compose/go.sum new file mode 100644 index 000000000..0fa958173 --- /dev/null +++ b/grid-compose/go.sum @@ -0,0 +1,71 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= +github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go= +github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grid-compose/internal/app/app.go b/grid-compose/internal/app/app.go new file mode 100644 index 000000000..b71afbc48 --- /dev/null +++ b/grid-compose/internal/app/app.go @@ -0,0 +1,127 @@ +package app + +import ( + "fmt" + "os" + "sort" + "strconv" + + "github.com/cosmos/go-bip39" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/pkg/parser/config" +) + +// App is the main application struct that holds the client and the config data +type App struct { + Client *deployer.TFPluginClient + Config *config.Config +} + +// NewApp creates a new instance of the application +func NewApp(net, mnemonic, configPath string) (*App, error) { + if !validateCredentials(mnemonic, net) { + return nil, fmt.Errorf("invalid mnemonic or network") + } + + configFile, err := os.Open(configPath) + if err != nil { + return nil, err + } + + defer configFile.Close() + + config := config.NewConfig() + err = config.LoadConfigFromReader(configFile) + if err != nil { + return nil, fmt.Errorf("failed to load config from file %w", err) + } + + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config %w", err) + } + + client, err := deployer.NewTFPluginClient(mnemonic, deployer.WithNetwork(net)) + if err != nil { + return nil, fmt.Errorf("failed to load grid client %w", err) + } + + return &App{ + Config: config, + Client: &client, + }, nil +} + +// GetProjectName returns the project name for the given key +func (a *App) GetProjectName(key string) string { + return fmt.Sprintf("vm/compose/%v/%v", a.Client.TwinID, key) +} + +// GetDeploymentName returns the deployment name for the given key +func (a *App) GetDeploymentName(key string) string { + return fmt.Sprintf("dl_%v", key) +} + +// GenerateDefaultNetworkName generates a default network name based on the sorted service names. +func (a *App) GenerateDefaultNetworkName() string { + var serviceNames []string + for serviceName := range a.Config.Services { + serviceNames = append(serviceNames, serviceName) + } + sort.Strings(serviceNames) + + var defaultNetName string + for _, serviceName := range serviceNames { + defaultNetName += serviceName[:2] + } + + return fmt.Sprintf("net_%s", defaultNetName) +} + +func (a *App) loadCurrentNodeDeployments(projectName string) error { + contracts, err := a.Client.ContractsGetter.ListContractsOfProjectName(projectName, true) + if err != nil { + return err + } + + var nodeID uint32 + + for _, contract := range contracts.NodeContracts { + contractID, err := strconv.ParseUint(contract.ContractID, 10, 64) + if err != nil { + return err + } + + nodeID = contract.NodeID + a.checkIfExistAndAppend(nodeID, contractID) + } + + return nil +} + +func (a *App) checkIfExistAndAppend(node uint32, contractID uint64) { + for _, n := range a.Client.State.CurrentNodeDeployments[node] { + if n == contractID { + return + } + } + + a.Client.State.CurrentNodeDeployments[node] = append(a.Client.State.CurrentNodeDeployments[node], contractID) +} + +// validateCredentials validates the mnemonics and network values of the user +func validateCredentials(mnemonics, network string) bool { + return validateMnemonics(mnemonics) && validateNetwork(network) +} + +func validateMnemonics(mnemonics string) bool { + return bip39.IsMnemonicValid(mnemonics) +} + +func validateNetwork(network string) bool { + switch network { + case "test", "dev", "main", "qa": + return true + default: + return false + } +} diff --git a/grid-compose/internal/app/dependency/dependency.go b/grid-compose/internal/app/dependency/dependency.go new file mode 100644 index 000000000..a42b73c88 --- /dev/null +++ b/grid-compose/internal/app/dependency/dependency.go @@ -0,0 +1,82 @@ +// Dependency package provides a directed graph of dependencies between services and resolves them +package dependency + +import ( + "fmt" + "log" + "slices" +) + +// DRGraph represents a directed graph of dependencies +type DRGraph struct { + Root *DRNode + Nodes map[string]*DRNode +} + +// NewDRGraph creates a new directed graph +func NewDRGraph(root *DRNode) *DRGraph { + return &DRGraph{ + Nodes: make(map[string]*DRNode), + Root: root, + } +} + +// DRNode represents a node in the directed graph +type DRNode struct { + Name string + Dependencies []*DRNode + Parent *DRNode +} + +// NewDRNode creates a new node in the directed graph +func NewDRNode(name string) *DRNode { + return &DRNode{ + Name: name, + Dependencies: []*DRNode{}, + } +} + +// AddDependency adds a dependency to the node +func (n *DRNode) AddDependency(dependency *DRNode) { + log.Printf("adding dependency %s -> %s", n.Name, dependency.Name) + n.Dependencies = append(n.Dependencies, dependency) +} + +// AddNode adds a node to the graph +func (g *DRGraph) AddNode(name string, node *DRNode) *DRNode { + g.Nodes[name] = node + + return node +} + +// ResolveDependencies resolves the dependencies of the node +func (g *DRGraph) ResolveDependencies(node *DRNode, resolved []*DRNode, unresolved []*DRNode) ([]*DRNode, error) { + unresolved = append(unresolved, node) + + for _, dep := range node.Dependencies { + if slices.Contains(resolved, dep) { + continue + } + + if slices.Contains(unresolved, dep) { + return nil, fmt.Errorf("circular dependency detected %s -> %s", node.Name, dep.Name) + } + + var err error + resolved, err = g.ResolveDependencies(dep, resolved, unresolved) + if err != nil { + return nil, err + } + } + + resolved = append(resolved, node) + + for i, n := range unresolved { + if n == node { + unresolved = append(unresolved[:i], unresolved[i+1:]...) + break + } + } + + return resolved, nil +} diff --git a/grid-compose/internal/app/down.go b/grid-compose/internal/app/down.go new file mode 100644 index 000000000..ac014c6f0 --- /dev/null +++ b/grid-compose/internal/app/down.go @@ -0,0 +1,22 @@ +package app + +import ( + "github.com/rs/zerolog/log" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" +) + +// Down cancels all the deployments on the grid +// TODO: remove known hosts +func (a *App) Down() error { + if len(a.Config.Networks) == 0 { + a.Config.Networks[a.GenerateDefaultNetworkName()] = types.Network{} + } + for networkName := range a.Config.Networks { + projectName := a.GetProjectName(networkName) + log.Info().Str("projectName", projectName).Msg("canceling deployments") + if err := a.Client.CancelByProjectName(projectName); err != nil { + return err + } + } + return nil +} diff --git a/grid-compose/internal/app/healthcheck.go b/grid-compose/internal/app/healthcheck.go new file mode 100644 index 000000000..c75c524ae --- /dev/null +++ b/grid-compose/internal/app/healthcheck.go @@ -0,0 +1,150 @@ +package app + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/melbahja/goph" + "github.com/rs/zerolog/log" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" + "golang.org/x/crypto/ssh" +) + +// verifyHost verifies the host key of the server. +func verifyHost(host string, remote net.Addr, key ssh.PublicKey) error { + // Check if the host is in known hosts file. + hostFound, err := goph.CheckKnownHost(host, remote, key, "") + + // Host in known hosts but key mismatch! + // Maybe because of MAN IN THE MIDDLE ATTACK! + if hostFound && err != nil { + + return err + } + + // handshake because public key already exists. + if hostFound && err == nil { + return nil + } + + // Add the new host to known hosts file. + return goph.AddKnownHost(host, remote, key, "") +} + +// runHealthCheck runs the health check on the VM +func runHealthCheck(healthCheck types.HealthCheck, user, ipAddr string) error { + var auth goph.Auth + var err error + + if goph.HasAgent() { + log.Info().Msg("using ssh agent for authentication") + auth, err = goph.UseAgent() + } else { + log.Info().Msg("using private key for authentication") + var sshDir, privKeyPath string + sshDir, err = getUserSSHDir() + if err != nil { + return err + } + privKeyPath = filepath.Join(sshDir, "id_rsa") + auth, err = goph.Key(privKeyPath, "") + } + + if err != nil { + return err + } + + timeoutDuration, err := time.ParseDuration(healthCheck.Timeout) + if err != nil { + return fmt.Errorf("invalid timeout format %w", err) + } + + startTime := time.Now() + var client *goph.Client + + for { + elapsedTime := time.Since(startTime) + if elapsedTime >= timeoutDuration { + return fmt.Errorf("timeout reached while waiting for SSH connection") + } + + remainingTime := timeoutDuration - elapsedTime + + client, err = goph.NewConn(&goph.Config{ + User: user, + Port: 22, + Addr: ipAddr, + Auth: auth, + Callback: verifyHost, + }) + + if err == nil { + defer client.Close() + break + } + + log.Info().Err(err).Msg("ssh connection attempt failed, retrying...") + time.Sleep(time.Second) + + if remainingTime < time.Second { + time.Sleep(remainingTime) + } + } + + command := strings.Join(healthCheck.Test, " ") + + intervalDuration, err := time.ParseDuration(healthCheck.Interval) + if err != nil { + return fmt.Errorf("invalid interval format %w", err) + } + + for i := 0; i < int(healthCheck.Retries); i++ { + ctx, cancel := context.WithTimeout(context.Background(), timeoutDuration) + defer cancel() + + out, err := runCommandWithContext(ctx, client, command) + if err == nil { + log.Info().Msgf("health check succeeded %s", string(out)) + return nil + } + + log.Info().Str("attempt", fmt.Sprintf("%d/%d", i+1, healthCheck.Retries)).Err(err).Msg("health check failed, retrying...") + time.Sleep(intervalDuration) + } + + return fmt.Errorf("health check failed after %d retries %w", healthCheck.Retries, err) +} + +// runCommandWithContext runs the command on the client with context and returns the output and error +func runCommandWithContext(ctx context.Context, client *goph.Client, command string) ([]byte, error) { + done := make(chan struct{}) + var out []byte + var err error + + go func() { + out, err = client.Run(command) + close(done) + }() + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("command timed out") + case <-done: + return out, err + } +} + +// getUserSSHDir returns the path to the user's SSH directory(e.g. ~/.ssh) +func getUserSSHDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + return filepath.Join(home, ".ssh"), nil +} diff --git a/grid-compose/internal/app/ps.go b/grid-compose/internal/app/ps.go new file mode 100644 index 000000000..28437da68 --- /dev/null +++ b/grid-compose/internal/app/ps.go @@ -0,0 +1,94 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/pkg/log" + "github.com/threefoldtech/zos/pkg/gridtypes" +) + +// Ps lists deployments on the grid with the option to show more details +func (a *App) Ps(ctx context.Context, verbose bool) error { + var output strings.Builder + outputMap := make(map[string]gridtypes.Deployment) + + if !verbose { + output.WriteString(fmt.Sprintf("%-15s | %-15s | %-15s | %-15s | %-15s | %-10s | %s\n", + "Deployment Name", "Node ID", "Network", "Services", "Storage", "State", "IP Address")) + output.WriteString(strings.Repeat("-", 150) + "\n") + } + + if len(a.Config.Networks) == 0 { + a.Config.Networks[a.GenerateDefaultNetworkName()] = types.Network{} + } + + for networkName := range a.Config.Networks { + projectName := a.GetProjectName(networkName) + + if err := a.loadCurrentNodeDeployments(projectName); err != nil { + return err + } + + contracts, err := a.Client.ContractsGetter.ListContractsOfProjectName(projectName) + if err != nil { + return err + } + + for _, contract := range contracts.NodeContracts { + nodeClient, err := a.Client.State.NcPool.GetNodeClient(a.Client.SubstrateConn, contract.NodeID) + if err != nil { + return err + } + + contId, _ := strconv.ParseUint(contract.ContractID, 10, 64) + dl, err := nodeClient.DeploymentGet(ctx, contId) + if err != nil { + return err + } + + dlAdded := false + for _, wl := range dl.Workloads { + if wl.Type.String() != "zmachine" { + continue + } + + vm, err := workloads.NewVMFromWorkload(&wl, &dl) + if err != nil { + return err + } + + log.WriteVmDetails(&output, vm, wl, a.GetDeploymentName(wl.Name.String()), contract.NodeID, dlAdded, getVmAddresses(vm)) + dlAdded = true + } + + dlData, err := workloads.ParseDeploymentData(dl.Metadata) + if err != nil { + return err + } + + if verbose { + outputMap[dlData.Name] = dl + } + } + } + + if verbose { + out, err := json.MarshalIndent(outputMap, "", " ") + if err != nil { + return err + } + + fmt.Println(string(out)) + return nil + } + + // print for better formatting + fmt.Printf("\n%s\n", output.String()) + return nil +} diff --git a/grid-compose/internal/app/up.go b/grid-compose/internal/app/up.go new file mode 100644 index 000000000..3aa6a6445 --- /dev/null +++ b/grid-compose/internal/app/up.go @@ -0,0 +1,89 @@ +package app + +import ( + "context" + + "github.com/rs/zerolog/log" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app/dependency" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/convert" +) + +// Up deploys the services described in the config file +func (a *App) Up(ctx context.Context) error { + defaultNetName := a.GenerateDefaultNetworkName() + deploymentData, err := convert.ConvertConfigToDeploymentData(ctx, a.Client, a.Config, defaultNetName) + if err != nil { + return err + } + + networks := buildNetworks(deploymentData.NetworkNodeMap, a.Config.Networks, defaultNetName, a.GetProjectName) + + resolvedServices, err := deploymentData.ServicesGraph.ResolveDependencies(deploymentData.ServicesGraph.Root, []*dependency.DRNode{}, []*dependency.DRNode{}) + if err != nil { + return err + } + + // maybe add a deployed field for both services and networks instead of using maps and slices + deployedDls := make([]*workloads.Deployment, 0) + deployedNets := make(map[string]*workloads.ZNet, 0) + for _, resService := range resolvedServices { + serviceName := resService.Name + if serviceName == "root" { + continue + } + + service := a.Config.Services[serviceName] + + var network *workloads.ZNet + if service.Network == "" { + network = networks[defaultNetName] + service.Network = network.Name + } else { + network = networks[service.Network] + } + + vm, err := convert.ConvertServiceToVM(&service, serviceName, network.Name) + if err != nil { + return err + } + + err = assignMyCeliumKeys(network, vm.MyceliumIPSeed) + if err != nil { + return err + } + + disks, mounts, err := buildStorage(service.Volumes, a.Config.Volumes) + if err != nil { + return err + } + vm.Mounts = mounts + + if err := vm.Validate(); err != nil { + return rollback(ctx, a.Client, deployedDls, deployedNets, err) + } + + if _, ok := deployedNets[network.Name]; !ok { + deployedNets[network.Name] = network + log.Info().Str("name", network.Name).Uint32("node_id", network.Nodes[0]).Msg("deploying network...") + if err := network.Validate(); err != nil { + return rollback(ctx, a.Client, deployedDls, deployedNets, err) + } + + if err := a.Client.NetworkDeployer.Deploy(ctx, network); err != nil { + return rollback(ctx, a.Client, deployedDls, deployedNets, err) + } + log.Info().Msg("deployed successfully") + } + + deployedDl, err := deployVM(ctx, a.Client, vm, disks, network, a.GetDeploymentName(serviceName), service.HealthCheck) + deployedDls = append(deployedDls, &deployedDl) + if err != nil { + return rollback(ctx, a.Client, deployedDls, deployedNets, err) + } + } + + log.Info().Msg("all deployments deployed successfully") + + return nil +} diff --git a/grid-compose/internal/app/up_utils.go b/grid-compose/internal/app/up_utils.go new file mode 100644 index 000000000..5deb8b7fb --- /dev/null +++ b/grid-compose/internal/app/up_utils.go @@ -0,0 +1,180 @@ +package app + +import ( + "context" + "net" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" + "github.com/threefoldtech/zos/pkg/gridtypes" +) + +// deployVM deploys a vm on the grid +func deployVM(ctx context.Context, client *deployer.TFPluginClient, vm workloads.VM, disks []workloads.Disk, network *workloads.ZNet, dlName string, healthCheck *types.HealthCheck) (workloads.Deployment, error) { + // volumes is nil until it is clear what it is used for + dl := workloads.NewDeployment(dlName, network.Nodes[0], network.SolutionType, nil, network.Name, disks, nil, []workloads.VM{vm}, nil, nil) + if err := dl.Validate(); err != nil { + return workloads.Deployment{}, err + } + + log.Info().Str("name", vm.Name).Uint32("node_id", dl.NodeID).Msg("deploying vm...") + if err := client.DeploymentDeployer.Deploy(ctx, &dl); err != nil { + return workloads.Deployment{}, err + } + log.Info().Msg("deployed successfully") + + resDl, err := client.State.LoadDeploymentFromGrid(ctx, dl.NodeID, dl.Name) + if err != nil { + return workloads.Deployment{}, errors.Wrapf(err, "failed to load vm from node %d", dl.NodeID) + } + + if healthCheck != nil { + log.Info().Msg("running health check...") + + log.Info().Str("addr", strings.Split(resDl.Vms[0].ComputedIP, "/")[0]).Msg("") + if err := runHealthCheck(*healthCheck, "root", strings.Split(resDl.Vms[0].ComputedIP, "/")[0]); err != nil { + return resDl, err + } + + } + + return resDl, nil +} + +// buildStorage converts the config volumes to disks and mounts and returns them. +func buildStorage(serviceVolumes []string, volumes map[string]types.Volume) ([]workloads.Disk, []workloads.Mount, error) { + var disks []workloads.Disk + mounts := make([]workloads.Mount, 0) + for _, volumeName := range serviceVolumes { + volume := volumes[volumeName] + + size, err := strconv.Atoi(strings.TrimSuffix(volume.Size, "GB")) + + if err != nil { + return nil, nil, err + } + + disk := workloads.Disk{ + Name: volumeName, + SizeGB: size, + } + + disks = append(disks, disk) + + mounts = append(mounts, workloads.Mount{ + DiskName: disk.Name, + MountPoint: volume.MountPoint, + }) + } + + return disks, mounts, nil +} + +// buildNetworks converts the networks in the config to ZNet workloads. +// TODO: needs to be refactored +func buildNetworks(networkNodeMap map[string]*types.NetworkData, networks map[string]types.Network, defaultNetName string, getProjectName func(string) string) map[string]*workloads.ZNet { + zNets := make(map[string]*workloads.ZNet, 0) + if _, ok := networkNodeMap[defaultNetName]; ok { + zNets[defaultNetName] = &workloads.ZNet{ + Name: defaultNetName, + IPRange: gridtypes.NewIPNet(net.IPNet{ + IP: net.IPv4(10, 20, 0, 0), + Mask: net.CIDRMask(16, 32), + }), + AddWGAccess: false, + Nodes: []uint32{networkNodeMap[defaultNetName].NodeID}, + SolutionType: getProjectName(defaultNetName), + } + } + + for networkName, network := range networks { + zNets[networkName] = &workloads.ZNet{ + Name: network.Name, + Description: network.Description, + IPRange: gridtypes.NewIPNet(generateIPNet(network.IPRange.IP, network.IPRange.Mask)), + AddWGAccess: network.AddWGAccess, + MyceliumKeys: network.MyceliumKeys, + Nodes: []uint32{networkNodeMap[networkName].NodeID}, + SolutionType: getProjectName(networkName), + } + + } + + return zNets +} + +// generateIPNet generates a net.IPNet from the given IP and mask. +func generateIPNet(ip types.IP, mask types.IPMask) net.IPNet { + var ipNet net.IPNet + + switch ip.Type { + case "ipv4": + ipSplit := strings.Split(ip.IP, ".") + byte1, _ := strconv.ParseUint(ipSplit[0], 10, 8) + byte2, _ := strconv.ParseUint(ipSplit[1], 10, 8) + byte3, _ := strconv.ParseUint(ipSplit[2], 10, 8) + byte4, _ := strconv.ParseUint(ipSplit[3], 10, 8) + + ipNet.IP = net.IPv4(byte(byte1), byte(byte2), byte(byte3), byte(byte4)) + default: + return ipNet + } + + var maskIP net.IPMask + + switch mask.Type { + case "cidr": + maskSplit := strings.Split(mask.Mask, "/") + maskOnes, _ := strconv.ParseInt(maskSplit[0], 10, 8) + maskBits, _ := strconv.ParseInt(maskSplit[1], 10, 8) + + maskIP = net.CIDRMask(int(maskOnes), int(maskBits)) + ipNet.Mask = maskIP + default: + return ipNet + } + + return ipNet +} + +// assignMyCeliumKeys assigns mycelium keys to the network nodes. +func assignMyCeliumKeys(network *workloads.ZNet, myceliumIPSeed []byte) error { + keys := make(map[uint32][]byte) + if len(myceliumIPSeed) != 0 { + for _, node := range network.Nodes { + key, err := workloads.RandomMyceliumKey() + if err != nil { + return err + } + keys[node] = key + } + } + + network.MyceliumKeys = keys + return nil +} + +func rollback(ctx context.Context, client *deployer.TFPluginClient, deployedDls []*workloads.Deployment, deployedNets map[string]*workloads.ZNet, err error) error { + log.Info().Msg("an error occurred while deploying, canceling all deployments") + log.Info().Msg("canceling networks...") + for _, network := range deployedNets { + if err := client.NetworkDeployer.Cancel(ctx, network); err != nil { + return err + } + } + + log.Info().Msg("canceling deployments...") + for _, deployment := range deployedDls { + if err := client.DeploymentDeployer.Cancel(ctx, deployment); err != nil { + return err + } + } + + log.Info().Msg("all deployments canceled successfully") + return err +} diff --git a/grid-compose/internal/app/vm_addresses.go b/grid-compose/internal/app/vm_addresses.go new file mode 100644 index 000000000..843b4407d --- /dev/null +++ b/grid-compose/internal/app/vm_addresses.go @@ -0,0 +1,30 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" +) + +func getVmAddresses(vm workloads.VM) string { + var addresses strings.Builder + + if vm.IP != "" { + addresses.WriteString(fmt.Sprintf("wireguard: %v, ", vm.IP)) + } + if vm.Planetary { + addresses.WriteString(fmt.Sprintf("yggdrasil: %v, ", vm.PlanetaryIP)) + } + if vm.PublicIP { + addresses.WriteString(fmt.Sprintf("publicIp4: %v, ", vm.ComputedIP)) + } + if vm.PublicIP6 { + addresses.WriteString(fmt.Sprintf("publicIp6: %v, ", vm.ComputedIP6)) + } + if len(vm.MyceliumIPSeed) != 0 { + addresses.WriteString(fmt.Sprintf("mycelium: %v, ", vm.MyceliumIP)) + } + + return strings.TrimSuffix(addresses.String(), ", ") +} diff --git a/grid-compose/internal/convert/convert.go b/grid-compose/internal/convert/convert.go new file mode 100644 index 000000000..cbb41e953 --- /dev/null +++ b/grid-compose/internal/convert/convert.go @@ -0,0 +1,192 @@ +package convert + +import ( + "context" + "crypto/rand" + "fmt" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/deployer" + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app/dependency" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/pkg/parser/config" + proxy_types "github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/pkg/types" + "github.com/threefoldtech/zos/pkg/gridtypes/zos" +) + +const ( + minCPU = 1 + minMemory = 2048 + minRootfs = 2048 +) + +// ConvertConfigToDeploymentData converts the config to deployment data that will be used to deploy the services +func ConvertConfigToDeploymentData(ctx context.Context, client *deployer.TFPluginClient, config *config.Config, defaultNetName string) (*types.DeploymentData, error) { + deploymentData := &types.DeploymentData{ + NetworkNodeMap: make(map[string]*types.NetworkData, 0), + ServicesGraph: dependency.NewDRGraph(dependency.NewDRNode("root")), + } + + for serviceName, service := range config.Services { + svc := service + var netName string + if svc.Network == "" { + netName = defaultNetName + } else { + netName = svc.Network + } + + if _, ok := deploymentData.NetworkNodeMap[netName]; !ok { + deploymentData.NetworkNodeMap[netName] = &types.NetworkData{ + NodeID: svc.NodeID, + Services: make(map[string]*types.Service, 0), + } + } + + if deploymentData.NetworkNodeMap[netName].NodeID == 0 && svc.NodeID != 0 { + deploymentData.NetworkNodeMap[netName].NodeID = svc.NodeID + } + + if svc.NodeID != 0 && svc.NodeID != deploymentData.NetworkNodeMap[netName].NodeID { + return nil, fmt.Errorf("service name %s node_id %d should be the same for all or some or left blank for services in the same network", serviceName, svc.NodeID) + } + + deploymentData.NetworkNodeMap[netName].Services[serviceName] = &svc + + svcNode, ok := deploymentData.ServicesGraph.Nodes[serviceName] + if !ok { + svcNode = dependency.NewDRNode( + serviceName, + ) + + deploymentData.ServicesGraph.AddNode(serviceName, svcNode) + } + + svcRootNode := deploymentData.ServicesGraph.Root + + for _, dep := range svc.DependsOn { + if _, ok := config.Services[dep]; !ok { + return nil, fmt.Errorf("service %s depends on %s which does not exist", serviceName, dep) + } + + depNode, ok := deploymentData.ServicesGraph.Nodes[dep] + if !ok { + depNode = dependency.NewDRNode(dep) + } + + svcNode.AddDependency(depNode) + depNode.Parent = svcNode + deploymentData.ServicesGraph.AddNode(dep, depNode) + } + + if svcNode.Parent == nil { + svcNode.Parent = svcRootNode + svcRootNode.AddDependency(svcNode) + } + } + + if err := getMissingNodes(ctx, deploymentData.NetworkNodeMap, client); err != nil { + return nil, err + } + + return deploymentData, nil +} + +// ConvertServiceToVM converts the service to a the VM workload that will be used to deploy a virtual machine on the grid +func ConvertServiceToVM(service *types.Service, serviceName, networkName string) (workloads.VM, error) { + vm := workloads.VM{ + Name: serviceName, + Flist: service.Flist, + Entrypoint: service.Entrypoint, + CPU: int(service.Resources.CPU), + Memory: int(service.Resources.Memory), + RootfsSize: int(service.Resources.Rootfs), + NetworkName: networkName, + } + + if vm.RootfsSize == 0 { + vm.RootfsSize = minRootfs + } + if vm.CPU == 0 { + vm.CPU = minCPU + } + if vm.Memory == 0 { + vm.Memory = minMemory + } + + if err := assignNetworksTypes(&vm, service.IPTypes); err != nil { + return workloads.VM{}, err + } + return vm, nil +} + +// getMissingNodes gets the missing nodes for the deployment data. +// It filters the nodes based on the resources required by the services in one network. +// TODO: Calculate total MRU and SRU while populating the deployment data +func getMissingNodes(ctx context.Context, networkNodeMap map[string]*types.NetworkData, client *deployer.TFPluginClient) error { + for _, deploymentData := range networkNodeMap { + if deploymentData.NodeID != 0 { + continue + } + + // freeCRU is not in NodeFilter? + var freeMRU, freeSRU uint64 + + for _, service := range deploymentData.Services { + freeMRU += service.Resources.Memory + freeSRU += service.Resources.Rootfs + } + + filter := proxy_types.NodeFilter{ + Status: []string{"up"}, + FreeSRU: &freeSRU, + FreeMRU: &freeMRU, + } + + nodes, _, err := client.GridProxyClient.Nodes(ctx, filter, proxy_types.Limit{}) + if err != nil { + return err + } + + if len(nodes) == 0 || (len(nodes) == 1 && nodes[0].NodeID == 1) { + return fmt.Errorf("no available nodes") + } + + // TODO: still need to agree on logic to select the node + for _, node := range nodes { + if node.NodeID != 1 { + deploymentData.NodeID = uint32(node.NodeID) + break + } + } + } + + return nil +} + +func assignNetworksTypes(vm *workloads.VM, ipTypes []string) error { + for _, ipType := range ipTypes { + switch ipType { + case "ipv4": + vm.PublicIP = true + case "ipv6": + vm.PublicIP6 = true + case "ygg": + vm.Planetary = true + case "myc": + seed, err := getRandomMyceliumIPSeed() + if err != nil { + return fmt.Errorf("failed to get mycelium seed %w", err) + } + vm.MyceliumIPSeed = seed + } + } + + return nil +} + +func getRandomMyceliumIPSeed() ([]byte, error) { + key := make([]byte, zos.MyceliumIPSeedLen) + _, err := rand.Read(key) + return key, err +} diff --git a/grid-compose/internal/types/deployment.go b/grid-compose/internal/types/deployment.go new file mode 100644 index 000000000..2730bf18f --- /dev/null +++ b/grid-compose/internal/types/deployment.go @@ -0,0 +1,16 @@ +package types + +import "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/app/dependency" + +// DeploymentData is a helper struct to hold the deployment data to ease the deployment process. +type DeploymentData struct { + ServicesGraph *dependency.DRGraph + NetworkNodeMap map[string]*NetworkData +} + +// NetworkData is a helper struct to hold the network data to ease the deployment process. +// It holds the node id and the services that are part of a network. +type NetworkData struct { + NodeID uint32 + Services map[string]*Service +} diff --git a/grid-compose/internal/types/network.go b/grid-compose/internal/types/network.go new file mode 100644 index 000000000..f05a6072b --- /dev/null +++ b/grid-compose/internal/types/network.go @@ -0,0 +1,28 @@ +package types + +// Network represents the network configuration +type Network struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + IPRange IPNet `yaml:"range"` + AddWGAccess bool `yaml:"wg"` + MyceliumKeys map[uint32][]byte `yaml:"mycelium_keys"` +} + +// IPNet represents the IP and mask of a network +type IPNet struct { + IP IP `yaml:"ip"` + Mask IPMask `yaml:"mask"` +} + +// IP represents the IP of a network +type IP struct { + Type string `yaml:"type"` + IP string `yaml:"ip"` +} + +// IPMask represents the mask of a network +type IPMask struct { + Type string `yaml:"type"` + Mask string `yaml:"mask"` +} diff --git a/grid-compose/internal/types/service.go b/grid-compose/internal/types/service.go new file mode 100644 index 000000000..c9e2a1aa9 --- /dev/null +++ b/grid-compose/internal/types/service.go @@ -0,0 +1,99 @@ +package types + +import ( + "fmt" + "strconv" + "strings" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/pkg/parser" + "gopkg.in/yaml.v3" +) + +// Service represents a service in the deployment +type Service struct { + Flist string `yaml:"flist"` + Entrypoint string `yaml:"entrypoint,omitempty"` + Environment KVMap `yaml:"environment"` + Resources Resources `yaml:"resources"` + Volumes []string `yaml:"volumes"` + NodeID uint32 `yaml:"node_id"` + IPTypes []string `yaml:"ip_types"` + Network string `yaml:"network"` + HealthCheck *HealthCheck `yaml:"healthcheck,omitempty"` + DependsOn []string `yaml:"depends_on,omitempty"` +} + +// KVMap represents a key-value map and implements the Unmarshaler interface +type KVMap map[string]string + +// UnmarshalYAML unmarshals a YAML node into a KVMap +func (m *KVMap) UnmarshalYAML(value *yaml.Node) error { + var raw []string + if err := value.Decode(&raw); err != nil { + return err + } + + *m = make(map[string]string) + for _, ele := range raw { + kv := strings.SplitN(ele, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid kvmap format %s", ele) + } + (*m)[kv[0]] = kv[1] + } + + return nil +} + +// Resources represents the resources required by the service +type Resources struct { + CPU uint16 `yaml:"cpu"` + Memory uint64 `yaml:"memory"` + Rootfs uint64 `yaml:"rootfs"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for custom unmarshalling. +func (r *Resources) UnmarshalYAML(value *yaml.Node) error { + for i := 0; i < len(value.Content); i += 2 { + key := value.Content[i].Value + val := value.Content[i+1].Value + + switch key { + case "cpu": + cpuVal, err := strconv.ParseUint(val, 10, 16) + if err != nil { + return fmt.Errorf("invalid cpu value %w", err) + } + r.CPU = uint16(cpuVal) + case "memory": + memVal, err := parser.ParseStorage(val) + if err != nil { + return fmt.Errorf("invalid memory value %w", err) + } + r.Memory = memVal + + case "rootfs": + rootfsVal, err := parser.ParseStorage(val) + if err != nil { + return fmt.Errorf("invalid rootfs value %w", err) + } + r.Rootfs = rootfsVal + } + } + + return nil +} + +// HealthCheck represents the health check configuration for the service +type HealthCheck struct { + Test []string `yaml:"test"` + Interval string `yaml:"interval"` + Timeout string `yaml:"timeout"` + Retries uint `yaml:"retries"` +} + +// Volume represents a volume in the deployment +type Volume struct { + MountPoint string `yaml:"mountpoint"` + Size string `yaml:"size"` +} diff --git a/grid-compose/main.go b/grid-compose/main.go new file mode 100644 index 000000000..626715cfd --- /dev/null +++ b/grid-compose/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/grid-compose/pkg/log/vm_details.go b/grid-compose/pkg/log/vm_details.go new file mode 100644 index 000000000..ae7626a22 --- /dev/null +++ b/grid-compose/pkg/log/vm_details.go @@ -0,0 +1,42 @@ +package log + +import ( + "fmt" + "strings" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-client/workloads" + "github.com/threefoldtech/zos/pkg/gridtypes" +) + +// WriteVmDetails writes the details of a VM to the output string builder +func WriteVmDetails(output *strings.Builder, vm workloads.VM, wl gridtypes.Workload, deploymentName string, nodeID uint32, dlAdded bool, vmAddresses string) { + if !dlAdded { + if len(vm.Mounts) < 1 { + output.WriteString(fmt.Sprintf("%-15s | %-15d | %-15s | %-15s | %-15s | %-10s | %s\n", + deploymentName, nodeID, vm.NetworkName, vm.Name, "None", wl.Result.State, vmAddresses)) + return + } + + output.WriteString(fmt.Sprintf("%-15s | %-15d | %-15s | %-15s | %-15s | %-10s | %s\n", + deploymentName, nodeID, vm.NetworkName, vm.Name, vm.Mounts[0].DiskName, wl.Result.State, vmAddresses)) + + for _, mount := range vm.Mounts[1:] { + output.WriteString(fmt.Sprintf("%-15s | %-15s | %-15s | %-15s | %-15s | %-10s | %s\n", + strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), mount.DiskName, wl.Result.State, strings.Repeat("-", 47))) + } + } else { + if len(vm.Mounts) < 1 { + output.WriteString(fmt.Sprintf("%-15s | %-15s | %-15s | %-15s | %-15s | %-10s | %s\n", + strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), vm.Name, "None", wl.Result.State, vmAddresses)) + return + } + + output.WriteString(fmt.Sprintf("%-15s | %-15s | %-15s | %-15s | %-15s | %-10s | %s\n", + strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), vm.Name, vm.Mounts[0].DiskName, wl.Result.State, vmAddresses)) + + for _, mount := range vm.Mounts[1:] { + output.WriteString(fmt.Sprintf("%-15s | %-15s | %-15s | %-15s | %-15s | %-10s | %s\n", + strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), strings.Repeat("-", 15), mount.DiskName, wl.Result.State, strings.Repeat("-", 15))) + } + } +} diff --git a/grid-compose/pkg/parser/config/config.go b/grid-compose/pkg/parser/config/config.go new file mode 100644 index 000000000..f8e453f09 --- /dev/null +++ b/grid-compose/pkg/parser/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "errors" + "fmt" + "io" + + "github.com/threefoldtech/tfgrid-sdk-go/grid-compose/internal/types" + "gopkg.in/yaml.v3" +) + +var ( + ErrVersionNotSet = errors.New("version not set") + ErrNetworkTypeNotSet = errors.New("network type not set") + ErrServiceFlistNotSet = errors.New("service flist not set") + ErrStorageTypeNotSet = errors.New("storage type not set") + ErrStorageSizeNotSet = errors.New("storage size not set") +) + +// Config represents the configuration file content +type Config struct { + Version string `yaml:"version"` + Networks map[string]types.Network `yaml:"networks"` + Services map[string]types.Service `yaml:"services"` + Volumes map[string]types.Volume `yaml:"volumes"` +} + +// NewConfig creates a new instance of the configuration +func NewConfig() *Config { + return &Config{ + Networks: make(map[string]types.Network), + Services: make(map[string]types.Service), + Volumes: make(map[string]types.Volume), + } +} + +// LoadConfigFromReader loads the configuration file content from a reader +func (c *Config) LoadConfigFromReader(configFile io.Reader) error { + content, err := io.ReadAll(configFile) + if err != nil { + return fmt.Errorf("failed to read file %w", err) + } + err = yaml.Unmarshal(content, &c) + if err != nil { + return fmt.Errorf("failed to unmarshal yaml %w", err) + } + return nil +} + +// Validate validates the configuration file content +// TODO: Create more validation rules +func (c *Config) Validate() (err error) { + if c.Version == "" { + return ErrVersionNotSet + } + + for name, service := range c.Services { + if len(name) > 50 { + return fmt.Errorf("service name %s is too long", name) + } + + if service.Flist == "" { + return fmt.Errorf("%w for service %s", ErrServiceFlistNotSet, name) + } + + if service.Entrypoint == "" { + return fmt.Errorf("entrypoint not set for service %s", name) + } + + if service.Resources.Memory != 0 && service.Resources.Memory < 256 { + return fmt.Errorf("minimum memory resource is 256 megabytes for service %s", name) + } + + if service.Resources.Rootfs != 0 && service.Resources.Rootfs < 2048 { + return fmt.Errorf("minimum rootfs resource is 2 gigabytes for service %s", name) + } + + } + + return nil +} diff --git a/grid-compose/pkg/parser/storage.go b/grid-compose/pkg/parser/storage.go new file mode 100644 index 000000000..8b937fec1 --- /dev/null +++ b/grid-compose/pkg/parser/storage.go @@ -0,0 +1,40 @@ +package parser + +import ( + "fmt" + "strconv" + "strings" +) + +func ParseStorage(storage string) (uint64, error) { + var multiplier uint64 = 1 + var numString, unit string + + storage = strings.ToLower(strings.TrimSpace(storage)) + + if !strings.HasSuffix(storage, "mb") && !strings.HasSuffix(storage, "gb") && !strings.HasSuffix(storage, "tb") { + unit = "mb" + numString = storage + } else { + unit = storage[len(storage)-2:] + numString = storage[:len(storage)-2] + } + + number, err := strconv.ParseUint(numString, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid number format %s", numString) + } + + switch unit { + case "mb": + multiplier = 1 + case "gb": + multiplier = 1024 + case "tb": + multiplier = 1024 * 1024 + default: + return 0, fmt.Errorf("unsupported storage unit %s", unit) + } + + return (number * multiplier), nil +}