Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
whywaita committed Apr 28, 2024
1 parent 52908b5 commit 64b13ae
Show file tree
Hide file tree
Showing 8 changed files with 1,359 additions and 1 deletion.
42 changes: 42 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Build test
on:
push:
branches:
- "**"

jobs:
build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup go
uses: actions/setup-go@v4
with:
go-version: 1.x
- name: Build check
run: |
go build .
- name: Prepare test
run: |
mkdir -p ~/.aws
echo "[default]
region = us-west-2
" > ~/.aws/config
echo "[default]
aws_access_key_id = dummy
aws_secret_access_key = dummy
" > ~/.aws/credentials
- name: go test
run: |
go test -v ./...
- name: Dump docker logs on failure
if: failure()
uses: jwalton/gh-docker-logs@v2
- name: Run tmate
if: failure()
uses: mxschmitt/action-tmate@v3

60 changes: 60 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Release binary
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"

jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.x
- name: release binaries
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -u
export CGO_ENABLED=0
declare -a os=("darwin" "linux" "windows")
declare -a arch=("amd64" "arm64")
mkdir -p _output
SAVEIFS=$IFS
IFS=$'\n'
shoes_names=($(ls | grep shoes | tr -d '/'))
IFS=$SAVEIFS
for shoes in ${shoes_names[@]}; do
cd ${shoes}
for o in ${os[@]}; do
for a in ${arch[@]}; do
filename=""
if [ ${o} = "windows" ]; then
filename=${shoes}-${o}-${a}.exe
else
filename=${shoes}-${o}-${a}
fi
GOOS=${o} GOARCH=${a} go build -o ../_output/${filename} . &
done
done
cd ../
done
wait
GO111MODULE=off GOBIN=$(pwd)/bin go get github.com/tcnksm/ghr
TAG_NAME=${GITHUB_REF##*/}
bin/ghr -replace -draft ${TAG_NAME} _output
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,20 @@
# shoes-aws
# shoes-aws: shoes provider for [Amazon Web Services](https://aws.amazon.com)

## Setup

Please set environment values.

### Required

- `AWS_RESOURCE_TYPE_MAPPING`
- mapping from [resource_type](https://github.com/whywaita/myshoes/blob/master/docs/how-to-develop-shoes.md#resource-type) to instance type of AWS.
- e.g.) `{"nano": "c5a.large", "micro": "c5a.xlarge"}`
- Credential values for AWS
- AWS Shared Configuration
- See [official documents](https://docs.aws.amazon.com/sdkref/latest/guide/creds-config-files.html)

### Optional

- `AWS_IMAGE_ID`
- AMI ID for runner
- default: `ami-02868af3c3df4b3aa`
13 changes: 13 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module github.com/whywaita/myshoes-providers/shoes-aws

go 1.16

require (
github.com/aws/aws-sdk-go-v2 v1.7.1
github.com/aws/aws-sdk-go-v2/config v1.5.0
github.com/aws/aws-sdk-go-v2/service/ec2 v1.12.0
github.com/hashicorp/go-plugin v1.4.2
github.com/ory/dockertest/v3 v3.6.3 // indirect
github.com/whywaita/myshoes v1.9.0
google.golang.org/grpc v1.39.0
)
825 changes: 825 additions & 0 deletions go.sum

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"time"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"

"github.com/hashicorp/go-plugin"
pb "github.com/whywaita/myshoes/api/proto"
"github.com/whywaita/myshoes/pkg/datastore"
"google.golang.org/grpc"
)

// Environment key values
const (
EnvAWSImageID = "AWS_IMAGE_ID"
EnvAWSResourceTypeMapping = "AWS_RESOURCE_TYPE_MAPPING"

DefaultImageID = "ami-02868af3c3df4b3aa" // us-west-2 focal 20.04 LTS amd64 hvm:ebs-ssd
)

func main() {
handshake := plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "SHOES_PLUGIN_MAGIC_COOKIE",
MagicCookieValue: "are_you_a_shoes?",
}
pluginMap := map[string]plugin.Plugin{
"shoes_grpc": &AWSPlugin{},
}

plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshake,
Plugins: pluginMap,
GRPCServer: plugin.DefaultGRPCServer,
})
}

// AWSPlugin is plugin implement for AWS
type AWSPlugin struct {
plugin.Plugin
}

// GRPCServer is server of gRPC
func (p *AWSPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
ctx := context.Background()

server, err := newServer(ctx, "")
if err != nil {
return fmt.Errorf("failed to initialize server: %w", err)
}
pb.RegisterShoesServer(s, *server)

return nil
}

func newServer(ctx context.Context, endpoint string) (*AWS, error) {
cfg, err := config.LoadDefaultConfig(ctx, config.WithEndpointResolver(mockEndpointResolver(endpoint)))
if err != nil {
return nil, fmt.Errorf("failed to load SDK config: %w", err)
}
service := ec2.NewFromConfig(cfg)

imageID, mapping, err := loadConfig()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}

server := &AWS{
client: service,
imageID: imageID,
resourceMapping: mapping,
}

return server, nil
}

// GRPCClient is client of gRPC
func (p *AWSPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
return nil, nil
}

// AWS is interface of Amazon Web Service
type AWS struct {
pb.UnimplementedShoesServer

client *ec2.Client
imageID string
resourceMapping map[pb.ResourceType]string
}

func (a AWS) generateInput(script string, rt pb.ResourceType) *ec2.RunInstancesInput {
instanceCount := int32(1)

return &ec2.RunInstancesInput{
MaxCount: &instanceCount,
MinCount: &instanceCount,
ImageId: aws.String(a.imageID),
InstanceType: types.InstanceType(a.resourceMapping[rt]),
UserData: aws.String(script),
}
}

// AddInstance create an instance from AWS
func (a AWS) AddInstance(ctx context.Context, req *pb.AddInstanceRequest) (*pb.AddInstanceResponse, error) {
instanceID, ip, err := a.createRunnerInstance(ctx, req.RunnerName, req.SetupScript, req.ResourceType)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create a runner instance: %+v", err)
}

return &pb.AddInstanceResponse{
CloudId: instanceID,
ShoesType: "aws",
IpAddress: ip,
}, nil
}

func (a AWS) createRunnerInstance(ctx context.Context, runnerName, script string, resourceType pb.ResourceType) (string, string, error) {
input := a.generateInput(script, resourceType)

result, err := a.client.RunInstances(ctx, input)
if err != nil {
return "", "", fmt.Errorf("failed to create instance: %w", err)
}
instanceID := *result.Instances[0].InstanceId
ip := *result.Instances[0].PublicIpAddress

if _, err := a.client.CreateTags(ctx, &ec2.CreateTagsInput{
Resources: []string{instanceID},
Tags: []types.Tag{
{
Key: aws.String("Name"),
Value: aws.String(runnerName),
},
},
}); err != nil {
return "", "", fmt.Errorf("failed to attach tag: %w", err)
}

if _, err := a.client.StartInstances(ctx, &ec2.StartInstancesInput{
InstanceIds: []string{instanceID},
}); err != nil {
return "", "", fmt.Errorf("failed to start instance (id: %s): %w", instanceID, err)
}

return instanceID, ip, nil
}

// DeleteInstance delete an instance from AWS
func (a AWS) DeleteInstance(ctx context.Context, req *pb.DeleteInstanceRequest) (*pb.DeleteInstanceResponse, error) {
if err := a.deleteRunnerInstance(ctx, req.CloudId); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete runner instance: %+v", err)
}

return &pb.DeleteInstanceResponse{}, nil
}

func (a AWS) deleteRunnerInstance(ctx context.Context, instanceID string) error {
if _, err := a.client.TerminateInstances(ctx, &ec2.TerminateInstancesInput{
InstanceIds: []string{instanceID},
}); err != nil {
return fmt.Errorf("failed to terminate instance (id: %s): %w", instanceID, err)
}

waiter := ec2.NewInstanceTerminatedWaiter(a.client)
if err := waiter.Wait(ctx, &ec2.DescribeInstancesInput{
InstanceIds: []string{instanceID},
}, 5*time.Minute); err != nil {
return fmt.Errorf("failed to wait terminating instance (id: %s): %w", instanceID, err)
}

return nil
}

func loadConfig() (string, map[pb.ResourceType]string, error) {
var imageID string
if os.Getenv(EnvAWSImageID) != "" {
imageID = os.Getenv(EnvAWSImageID)
} else {
imageID = DefaultImageID
}

if os.Getenv(EnvAWSResourceTypeMapping) == "" {
return "", nil, fmt.Errorf("must be set %s", EnvAWSResourceTypeMapping)
}

m, err := readResourceTypeMapping(os.Getenv(EnvAWSResourceTypeMapping))
if err != nil {
return "", nil, fmt.Errorf("failed to read %s: %w", EnvAWSResourceTypeMapping, err)
}

return imageID, m, nil
}

func readResourceTypeMapping(env string) (map[pb.ResourceType]string, error) {
var mapping map[string]string
if err := json.Unmarshal([]byte(env), &mapping); err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
}

r := map[pb.ResourceType]string{}
for key, value := range mapping {
rt := datastore.UnmarshalResourceType(key)
if rt == datastore.ResourceTypeUnknown {
return nil, fmt.Errorf("%s is invalid resource type", key)
}

r[rt.ToPb()] = value
}

return r, nil
}

// mockEndpointResolver set endpoint to localhost localstack for testing.
func mockEndpointResolver(endpoint string) aws.EndpointResolverFunc {
return aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) {
if endpoint == "" {
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
}

if service == ec2.ServiceID && region == "shoes-aws-testing-region" {
return aws.Endpoint{
PartitionID: "aws",
URL: endpoint,
SigningRegion: "shoes-aws-testing-region",
}, nil
}

return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
}
Loading

0 comments on commit 64b13ae

Please sign in to comment.