diff --git a/app/container/aws-ecr/infra_config.go b/app/container/aws-ecr/infra_config.go new file mode 100644 index 0000000..dce39af --- /dev/null +++ b/app/container/aws-ecr/infra_config.go @@ -0,0 +1,57 @@ +package aws_ecr + +import ( + "context" + "encoding/base64" + "fmt" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/docker/docker/api/types" + nsaws "gopkg.in/nullstone-io/nullstone.v0/aws" + aws_ecr "gopkg.in/nullstone-io/nullstone.v0/contracts/aws-ecr" + "gopkg.in/nullstone-io/nullstone.v0/docker" + "log" + "strings" +) + +// InfraConfig provides a minimal understanding of the infrastructure provisioned for a module type=aws-fargate +type InfraConfig struct { + Outputs aws_ecr.Outputs +} + +func (c InfraConfig) Print(logger *log.Logger) { + logger.Printf("repository image url: %q\n", c.Outputs.ImageRepoUrl) +} + +func (c InfraConfig) GetEcrLoginAuth() (types.AuthConfig, error) { + ecrClient := ecr.NewFromConfig(nsaws.NewConfig(c.Outputs.ImagePusher, c.Outputs.Region)) + out, err := ecrClient.GetAuthorizationToken(context.TODO(), &ecr.GetAuthorizationTokenInput{}) + if err != nil { + return types.AuthConfig{}, err + } + if len(out.AuthorizationData) > 0 { + authData := out.AuthorizationData[0] + token, err := base64.StdEncoding.DecodeString(*authData.AuthorizationToken) + if err != nil { + return types.AuthConfig{}, fmt.Errorf("invalid authorization token: %w", err) + } + tokens := strings.SplitN(string(token), ":", 2) + return types.AuthConfig{ + Username: tokens[0], + Password: tokens[1], + ServerAddress: *authData.ProxyEndpoint, + }, nil + } + return types.AuthConfig{}, nil +} + +func (c InfraConfig) RetagImage(ctx context.Context, sourceUrl, targetUrl docker.ImageUrl) error { + dockerClient, err := docker.DiscoverDockerClient() + if err != nil { + return fmt.Errorf("error creating docker client: %w", err) + } + return dockerClient.ImageTag(ctx, sourceUrl.String(), targetUrl.String()) +} + +func (c InfraConfig) PushImage(ctx context.Context, targetUrl docker.ImageUrl, targetAuth types.AuthConfig) error { + return docker.PushImage(ctx, targetUrl, targetAuth) +} diff --git a/app/container/aws-ecr/provider.go b/app/container/aws-ecr/provider.go new file mode 100644 index 0000000..52729ab --- /dev/null +++ b/app/container/aws-ecr/provider.go @@ -0,0 +1,96 @@ +package aws_ecr + +import ( + "context" + "fmt" + "gopkg.in/nullstone-io/go-api-client.v0" + "gopkg.in/nullstone-io/go-api-client.v0/types" + "gopkg.in/nullstone-io/nullstone.v0/app" + "gopkg.in/nullstone-io/nullstone.v0/docker" + "gopkg.in/nullstone-io/nullstone.v0/outputs" + "log" + "os" + "strings" +) + +var ( + logger = log.New(os.Stderr, "", 0) +) + +var _ app.Provider = Provider{} + +type Provider struct { +} + +func (p Provider) identify(nsConfig api.Config, app *types.Application, workspace *types.Workspace) (*InfraConfig, error) { + logger.Printf("Identifying infrastructure for app %q\n", app.Name) + ic := &InfraConfig{} + retriever := outputs.Retriever{NsConfig: nsConfig} + if err := retriever.Retrieve(workspace, &ic.Outputs); err != nil { + return nil, fmt.Errorf("Unable to identify app infrastructure: %w", err) + } + ic.Print(logger) + return ic, nil +} + +func (p Provider) Push(nsConfig api.Config, app *types.Application, env *types.Environment, workspace *types.Workspace, userConfig map[string]string) error { + ic, err := p.identify(nsConfig, app, workspace) + if err != nil { + return err + } + + sourceUrl := docker.ParseImageUrl(userConfig["source"]) + + targetUrl := ic.Outputs.ImageRepoUrl + // NOTE: We expect --version from the user which is used as the image tag for the pushed image + if imageTag := userConfig["version"]; imageTag != "" { + targetUrl.Tag = imageTag + } else { + targetUrl.Tag = sourceUrl.Tag + } + if targetUrl.String() == "" { + return fmt.Errorf("cannot push if 'image_repo_url' module output is missing") + } + if !strings.Contains(targetUrl.Registry, "ecr") && + !strings.Contains(targetUrl.Registry, "amazonaws.com") { + return fmt.Errorf("this app only supports push to AWS ECR (image=%s)", targetUrl) + } + // NOTE: For now, we are assuming that the production docker image is hosted in ECR + // This will likely need to be refactored to support pushing to other image registries + if ic.Outputs.ImagePusher.AccessKeyId == "" { + return fmt.Errorf("cannot push without an authorized user, make sure 'image_pusher' output is not empty") + } + + // TODO: Add cancellation support so users can press Control+C to kill push + ctx := context.TODO() + + targetAuth, err := ic.GetEcrLoginAuth() + if err != nil { + return fmt.Errorf("error retrieving image registry credentials: %w", err) + } + fmt.Printf("%+v\n", targetAuth) + + logger.Printf("Retagging %s => %s\n", sourceUrl.String(), targetUrl.String()) + if err := ic.RetagImage(ctx, sourceUrl, targetUrl); err != nil { + return fmt.Errorf("error retagging image: %w", err) + } + + logger.Printf("Pushing %s\n", targetUrl.String()) + if err := ic.PushImage(ctx, targetUrl, targetAuth); err != nil { + return fmt.Errorf("error pushing image: %w", err) + } + + return nil +} + +// Deploy updates the app version +func (p Provider) Deploy(nsConfig api.Config, application *types.Application, env *types.Environment, workspace *types.Workspace, userConfig map[string]string) error { + version := userConfig["version"] + if version != "" { + logger.Printf("Updating app version to %q\n", version) + if err := app.UpdateVersion(nsConfig, application.Id, env.Name, version); err != nil { + return fmt.Errorf("error updating app version in nullstone: %w", err) + } + } + return nil +} diff --git a/app/container/aws-fargate/infra_config.go b/app/container/aws-fargate/infra_config.go index ea1dbf5..f1a66a9 100644 --- a/app/container/aws-fargate/infra_config.go +++ b/app/container/aws-fargate/infra_config.go @@ -28,7 +28,7 @@ func (c InfraConfig) Print(logger *log.Logger) { } func (c InfraConfig) GetTaskDefinition() (*ecstypes.TaskDefinition, error) { - ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer)) + ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer, c.Outputs.Region)) out1, err := ecsClient.DescribeServices(context.Background(), &ecs.DescribeServicesInput{ Services: []string{c.Outputs.ServiceName}, @@ -51,7 +51,7 @@ func (c InfraConfig) GetTaskDefinition() (*ecstypes.TaskDefinition, error) { } func (c InfraConfig) UpdateTaskImageTag(taskDefinition *ecstypes.TaskDefinition, imageTag string) (*ecstypes.TaskDefinition, error) { - ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer)) + ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer, c.Outputs.Region)) defIndex, err := c.findMainContainerDefinitionIndex(taskDefinition.ContainerDefinitions) if err != nil { @@ -120,7 +120,7 @@ func (c InfraConfig) findMainContainerDefinitionIndex(containerDefs []ecstypes.C } func (c InfraConfig) UpdateServiceTask(taskDefinitionArn string) error { - ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer)) + ecsClient := ecs.NewFromConfig(nsaws.NewConfig(c.Outputs.Cluster.Deployer, c.Outputs.Region)) _, err := ecsClient.UpdateService(context.Background(), &ecs.UpdateServiceInput{ Service: aws.String(c.Outputs.ServiceName), @@ -132,7 +132,7 @@ func (c InfraConfig) UpdateServiceTask(taskDefinitionArn string) error { } func (c InfraConfig) GetEcrLoginAuth() (types.AuthConfig, error) { - ecrClient := ecr.NewFromConfig(nsaws.NewConfig(c.Outputs.ImagePusher)) + ecrClient := ecr.NewFromConfig(nsaws.NewConfig(c.Outputs.ImagePusher, c.Outputs.Region)) out, err := ecrClient.GetAuthorizationToken(context.TODO(), &ecr.GetAuthorizationTokenInput{}) if err != nil { return types.AuthConfig{}, err diff --git a/app/serverless/aws-lambda/infra_config.go b/app/serverless/aws-lambda/infra_config.go index 6fe44c5..21b8bef 100644 --- a/app/serverless/aws-lambda/infra_config.go +++ b/app/serverless/aws-lambda/infra_config.go @@ -23,7 +23,7 @@ func (c InfraConfig) Print(logger *log.Logger) { } func (c InfraConfig) UploadArtifact(ctx context.Context, content io.Reader, version string) error { - s3Client := s3.NewFromConfig(nsaws.NewConfig(c.Outputs.Deployer)) + s3Client := s3.NewFromConfig(nsaws.NewConfig(c.Outputs.Deployer, c.Outputs.Region)) _, err := s3Client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(c.Outputs.ArtifactsBucketName), Key: aws.String(c.Outputs.ArtifactsKey(version)), @@ -33,7 +33,7 @@ func (c InfraConfig) UploadArtifact(ctx context.Context, content io.Reader, vers } func (c InfraConfig) UpdateLambdaVersion(ctx context.Context, version string) error { - λClient := lambda.NewFromConfig(nsaws.NewConfig(c.Outputs.Deployer)) + λClient := lambda.NewFromConfig(nsaws.NewConfig(c.Outputs.Deployer, c.Outputs.Region)) _, err := λClient.UpdateFunctionCode(ctx, &lambda.UpdateFunctionCodeInput{ FunctionName: aws.String(c.Outputs.LambdaName), DryRun: false, diff --git a/aws/new_config.go b/aws/new_config.go index 427ac8d..c14b976 100644 --- a/aws/new_config.go +++ b/aws/new_config.go @@ -13,14 +13,16 @@ const ( AwsTraceEnvVar = "AWS_TRACE" ) -func NewConfig(user caws.User) aws.Config { +func NewConfig(user caws.User, region string) aws.Config { awsConfig := aws.Config{} if os.Getenv(AwsTraceEnvVar) != "" { awsConfig.Logger = logging.NewStandardLogger(os.Stderr) awsConfig.ClientLogMode = aws.LogRequestWithBody | aws.LogResponseWithBody } awsConfig.Region = DefaultAwsRegion - // TODO: How do we set the region? + if region != "" { + awsConfig.Region = region + } awsConfig.Credentials = credentials.NewStaticCredentialsProvider(user.AccessKeyId, user.SecretAccessKey, "") return awsConfig } diff --git a/contracts/aws-ecr/outputs.go b/contracts/aws-ecr/outputs.go new file mode 100644 index 0000000..f6fef33 --- /dev/null +++ b/contracts/aws-ecr/outputs.go @@ -0,0 +1,12 @@ +package aws_ecr + +import ( + "gopkg.in/nullstone-io/nullstone.v0/contracts/aws" + "gopkg.in/nullstone-io/nullstone.v0/docker" +) + +type Outputs struct { + Region string `ns:"region"` + ImageRepoUrl docker.ImageUrl `ns:"image_repo_url,optional"` + ImagePusher aws.User `ns:"image_pusher,optional"` +} diff --git a/contracts/aws-fargate-service/outputs.go b/contracts/aws-fargate-service/outputs.go index 583f76e..5d1ef71 100644 --- a/contracts/aws-fargate-service/outputs.go +++ b/contracts/aws-fargate-service/outputs.go @@ -7,6 +7,7 @@ import ( ) type Outputs struct { + Region string `ns:"region"` ServiceName string `ns:"service_name"` ImageRepoUrl docker.ImageUrl `ns:"image_repo_url,optional"` ImagePusher aws.User `ns:"image_pusher,optional"` diff --git a/contracts/aws-lambda-service/outputs.go b/contracts/aws-lambda-service/outputs.go index 959e957..c4ddf4d 100644 --- a/contracts/aws-lambda-service/outputs.go +++ b/contracts/aws-lambda-service/outputs.go @@ -10,6 +10,7 @@ const ( ) type Outputs struct { + Region string `ns:"region"` Deployer aws.User `ns:"deployer"` LambdaArn string `ns:"lambda_arn"` LambdaName string `ns:"lambda_name"` diff --git a/nullstone/main.go b/nullstone/main.go index d392bf8..8df353d 100644 --- a/nullstone/main.go +++ b/nullstone/main.go @@ -5,6 +5,7 @@ import ( "github.com/urfave/cli" "gopkg.in/nullstone-io/go-api-client.v0/types" "gopkg.in/nullstone-io/nullstone.v0/app" + aws_ecr "gopkg.in/nullstone-io/nullstone.v0/app/container/aws-ecr" "gopkg.in/nullstone-io/nullstone.v0/app/container/aws-fargate" aws_lambda "gopkg.in/nullstone-io/nullstone.v0/app/serverless/aws-lambda" "gopkg.in/nullstone-io/nullstone.v0/cmd" @@ -23,6 +24,7 @@ func main() { appProviders := app.Providers{ types.CategoryAppContainer: { "service/aws-fargate": aws_fargate.Provider{}, + "service/aws-ecr": aws_ecr.Provider{}, }, // TODO: Add support for more categories and types //types.CategoryAppStaticSite: {