Skip to content

Commit

Permalink
Allow delayed creation of container app (#1196)
Browse files Browse the repository at this point in the history
- Allow `containerapp` host to provision the container app target resource as part of deployment
- Add better support for docker image tagging:
   - `tag` for custom tag
   - Defaulting image name to `projectName`/`serviceName`-`envName`, i.e. `todo-java-mongo-aca/app-dev`
 
Fixes #517
  • Loading branch information
weikanglim committed Dec 13, 2022
1 parent 31c7ebe commit d122f9a
Show file tree
Hide file tree
Showing 22 changed files with 760 additions and 232 deletions.
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ circleci
cflags
cmdsubst
containerapp
contoso
csharpapp
cupaloy
devel
Expand Down
1 change: 1 addition & 0 deletions cli/azd/pkg/project/framework_service_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type DockerProjectOptions struct {
Path string `json:"path"`
Context string `json:"context"`
Platform string `json:"platform"`
Tag string `json:"tag"`
}

type dockerProject struct {
Expand Down
51 changes: 43 additions & 8 deletions cli/azd/pkg/project/service_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/azureutil"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
Expand Down Expand Up @@ -69,7 +70,7 @@ func (sc *ServiceConfig) GetService(
return nil, fmt.Errorf("creating framework service: %w", err)
}

azureResource, err := sc.GetServiceResource(ctx, project.ResourceGroupName, env, azCli)
azureResource, err := sc.resolveServiceResource(ctx, project.ResourceGroupName, env, azCli, "provision")
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -232,12 +233,42 @@ const (
defaultServiceTag = "azd-service-name"
)

// resolveServiceResource resolves the service resource during service construction
func (sc *ServiceConfig) resolveServiceResource(
ctx context.Context,
resourceGroupName string,
env *environment.Environment,
azCli azcli.AzCli,
rerunCommand string,
) (azcli.AzCliResource, error) {
azureResource, err := sc.GetServiceResource(ctx, resourceGroupName, env, azCli, rerunCommand)

// If the service target supports delayed provisioning, the resource isn't expected to be found yet.
// Return the empty resource
var resourceNotFoundError *azureutil.ResourceNotFoundError
if err != nil &&
errors.As(err, &resourceNotFoundError) &&
ServiceTargetKind(sc.Host).SupportsDelayedProvisioning() {
return azureResource, nil
}

if err != nil {
return azcli.AzCliResource{}, err
}

return azureResource, nil
}

// GetServiceResources gets the specific azure service resource targeted by the service.
//
// rerunCommand specifies the command that users should rerun in case of misconfiguration.
// This is included in the error message if applicable
func (sc *ServiceConfig) GetServiceResource(
ctx context.Context,
resourceGroupName string,
env *environment.Environment,
azCli azcli.AzCli,
rerunCommand string,
) (azcli.AzCliResource, error) {
resources, err := sc.GetServiceResources(ctx, resourceGroupName, env, azCli)
if err != nil {
Expand All @@ -246,29 +277,33 @@ func (sc *ServiceConfig) GetServiceResource(

if strings.TrimSpace(sc.ResourceName) == "" { // A tag search was performed
if len(resources) == 0 {
return azcli.AzCliResource{}, fmt.Errorf(
err := fmt.Errorf(
//nolint:lll
"unable to find a provisioned resource tagged with '%s: %s'. Ensure the service resource is correctly tagged in your bicep files, and rerun provision",
"unable to find a resource tagged with '%s: %s'. Ensure the service resource is correctly tagged in your bicep files, and rerun %s",
defaultServiceTag,
sc.Name,
rerunCommand,
)
return azcli.AzCliResource{}, azureutil.ResourceNotFound(err)
}

if len(resources) != 1 {
return azcli.AzCliResource{}, fmt.Errorf(
//nolint:lll
"expecting only '1' resource tagged with '%s: %s', but found '%d'. Ensure a unique service resource is correctly tagged in your bicep files, and rerun provision",
"expecting only '1' resource tagged with '%s: %s', but found '%d'. Ensure a unique service resource is correctly tagged in your bicep files, and rerun %s",
defaultServiceTag,
sc.Name,
len(resources),
rerunCommand,
)
}
} else { // Name based search
if len(resources) == 0 {
return azcli.AzCliResource{},
fmt.Errorf(
"unable to find a provisioned resource with name '%s'. Ensure that a previous provision was successful",
sc.ResourceName)
err := fmt.Errorf(
"unable to find a resource with name '%s'. Ensure that resourceName in azure.yaml is valid, and rerun %s",
sc.ResourceName,
rerunCommand)
return azcli.AzCliResource{}, azureutil.ResourceNotFound(err)
}

// This can happen if multiple resources with different resource types are given the same name.
Expand Down
9 changes: 9 additions & 0 deletions cli/azd/pkg/project/service_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ func resourceTypeMismatchError(
)
}

// SupportsDelayedProvisioning returns true if the service target kind
// supports delayed provisioning resources at deployment time, otherwise false.
//
// As an example, ContainerAppTarget is able to provision the container app as part of deployment,
// and thus returns true.
func (st ServiceTargetKind) SupportsDelayedProvisioning() bool {
return st == ContainerAppTarget
}

var _ ServiceTarget = &appServiceTarget{}
var _ ServiceTarget = &containerAppTarget{}
var _ ServiceTarget = &functionAppTarget{}
Expand Down
74 changes: 63 additions & 11 deletions cli/azd/pkg/project/service_target_containerapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"log"
"strings"
"time"

"github.com/azure/azure-dev/cli/azd/pkg/azure"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
Expand All @@ -20,6 +19,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/azcli"
"github.com/azure/azure-dev/cli/azd/pkg/tools/docker"
"github.com/benbjohnson/clock"
)

type containerAppTarget struct {
Expand All @@ -30,6 +30,9 @@ type containerAppTarget struct {
docker *docker.Docker
console input.Console
commandRunner exec.CommandRunner

// Standard time library clock, unless mocked in tests
clock clock.Clock
}

func (at *containerAppTarget) RequiredExternalTools() []tools.ExternalTool {
Expand Down Expand Up @@ -67,11 +70,9 @@ func (at *containerAppTarget) Deploy(
}

fullTag := fmt.Sprintf(
"%s/%s/%s:azdev-deploy-%d",
"%s/%s",
loginServer,
at.resource.ResourceName(),
at.resource.ResourceName(),
time.Now().Unix(),
at.generateImageTag(),
)

// Tag image.
Expand Down Expand Up @@ -139,6 +140,25 @@ func (at *containerAppTarget) Deploy(
}
}

if at.resource.ResourceName() == "" {
targetResource, err := at.config.GetServiceResource(ctx, at.resource.ResourceGroupName(), at.env, at.cli, "deploy")
if err != nil {
return ServiceDeploymentResult{}, err
}

// Fill in the target resource
at.resource = environment.NewTargetResource(
at.env.GetSubscriptionId(),
at.resource.ResourceGroupName(),
targetResource.Name,
targetResource.Type,
)

if err := checkResourceType(at.resource); err != nil {
return ServiceDeploymentResult{}, err
}
}

progress <- "Fetching endpoints for container app service"
endpoints, err := at.Endpoints(ctx)
if err != nil {
Expand Down Expand Up @@ -174,6 +194,23 @@ func (at *containerAppTarget) Endpoints(ctx context.Context) ([]string, error) {
}
}

func (at *containerAppTarget) generateImageTag() string {
if at.config.Docker.Tag != "" {
return at.config.Docker.Tag
}

return fmt.Sprintf("%s/%s-%s:azdev-deploy-%d",
strings.ToLower(at.config.Project.Name),
strings.ToLower(at.config.Name),
strings.ToLower(at.env.GetEnvName()),
at.clock.Now().Unix(),
)
}

// NewContainerAppTarget creates the container app service target.
//
// The target resource can be partially filled with only ResourceGroupName, since container apps
// can be provisioned during deployment.
func NewContainerAppTarget(
config *ServiceConfig,
env *environment.Environment,
Expand All @@ -183,12 +220,14 @@ func NewContainerAppTarget(
console input.Console,
commandRunner exec.CommandRunner,
) (ServiceTarget, error) {
if !strings.EqualFold(resource.ResourceType(), string(infra.AzureResourceTypeContainerApp)) {
return nil, resourceTypeMismatchError(
resource.ResourceName(),
resource.ResourceType(),
infra.AzureResourceTypeContainerApp,
)
if resource.ResourceGroupName() == "" {
return nil, fmt.Errorf("missing resource group name: %s", resource.ResourceGroupName())
}

if resource.ResourceType() != "" {
if err := checkResourceType(resource); err != nil {
return nil, err
}
}

return &containerAppTarget{
Expand All @@ -199,5 +238,18 @@ func NewContainerAppTarget(
docker: docker,
console: console,
commandRunner: commandRunner,
clock: clock.New(),
}, nil
}

func checkResourceType(resource *environment.TargetResource) error {
if !strings.EqualFold(resource.ResourceType(), string(infra.AzureResourceTypeContainerApp)) {
return resourceTypeMismatchError(
resource.ResourceName(),
resource.ResourceType(),
infra.AzureResourceTypeContainerApp,
)
}

return nil
}
42 changes: 42 additions & 0 deletions cli/azd/pkg/project/service_target_containerapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
package project

import (
"fmt"
"strings"
"testing"

"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra"
"github.com/benbjohnson/clock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -59,3 +62,42 @@ func TestNewContainerAppTargetTypeValidation(t *testing.T) {
require.Error(t, err)
})
}

func Test_containerAppTarget_generateImageTag(t *testing.T) {
mockClock := clock.NewMock()
envName := "dev"
projectName := "my-app"
serviceName := "web"
defaultImageName := fmt.Sprintf("%s/%s-%s", projectName, serviceName, envName)
tests := []struct {
name string
dockerConfig DockerProjectOptions
want string
}{
{"Default",
DockerProjectOptions{},
fmt.Sprintf("%s:azdev-deploy-%d", defaultImageName, mockClock.Now().Unix())},
{"ImageTagSpecified",
DockerProjectOptions{
Tag: "contoso/contoso-image:latest",
},
"contoso/contoso-image:latest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
containerAppTarget := &containerAppTarget{
env: environment.EphemeralWithValues(envName, map[string]string{}),
config: &ServiceConfig{
Name: serviceName,
Host: "containerapp",
Project: &ProjectConfig{
Name: projectName,
},
Docker: tt.dockerConfig,
},
clock: mockClock}
tag := containerAppTarget.generateImageTag()
assert.Equal(t, tt.want, tag)
})
}
}

0 comments on commit d122f9a

Please sign in to comment.