Skip to content

Commit

Permalink
compose: Add azd add support for storage accounts (blob service) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
JeffreyCA authored Feb 11, 2025
1 parent cca07ad commit f9a9c1f
Show file tree
Hide file tree
Showing 14 changed files with 311 additions and 7 deletions.
2 changes: 2 additions & 0 deletions cli/azd/internal/cmd/add/add_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func Configure(

r.Name = "redis"
return r, nil
case project.ResourceTypeStorage:
return fillStorageDetails(ctx, r, console, p)
default:
return r, nil
}
Expand Down
129 changes: 129 additions & 0 deletions cli/azd/internal/cmd/add/add_configure_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package add

import (
"context"
"errors"
"fmt"
"strings"

"github.com/azure/azure-dev/cli/azd/internal/names"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/project"
)

const (
StorageDataTypeBlob = "Blobs"
)

func allStorageDataTypes() []string {
return []string{StorageDataTypeBlob}
}

func fillStorageDetails(
ctx context.Context,
r *project.ResourceConfig,
console input.Console,
p PromptOptions) (*project.ResourceConfig, error) {
if _, exists := p.PrjConfig.Resources["storage"]; exists {
return nil, fmt.Errorf("only one Storage resource is allowed at this time")
}

if r.Name == "" {
r.Name = "storage"
}

modelProps, ok := r.Props.(project.StorageProps)
if !ok {
return nil, fmt.Errorf("invalid resource properties")
}

selectedDataTypes, err := selectStorageDataTypes(ctx, console)
if err != nil {
return nil, err
}

for _, option := range selectedDataTypes {
switch option {
case StorageDataTypeBlob:
if err := fillBlobDetails(ctx, console, &modelProps); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported data type: %s", option)
}
}

r.Props = modelProps
return r, nil
}

func selectStorageDataTypes(ctx context.Context, console input.Console) ([]string, error) {
var selectedDataOptions []string
for {
var err error
selectedDataOptions, err = console.MultiSelect(ctx, input.ConsoleOptions{
Message: "What type of data do you want to store?",
Options: allStorageDataTypes(),
DefaultValue: []string{StorageDataTypeBlob},
})
if err != nil {
return nil, err
}

if len(selectedDataOptions) == 0 {
console.Message(ctx, output.WithErrorFormat("At least one data type must be selected"))
continue
}
break
}
return selectedDataOptions, nil
}

func fillBlobDetails(ctx context.Context, console input.Console, modelProps *project.StorageProps) error {
for {
containerName, err := console.Prompt(ctx, input.ConsoleOptions{
Message: "Input a blob container name to be created:",
Help: "Blob container name\n\n" +
"A blob container organizes a set of blobs, similar to a directory in a file system.",
})
if err != nil {
return err
}

if err := validateContainerName(containerName); err != nil {
console.Message(ctx, err.Error())
continue
}
modelProps.Containers = append(modelProps.Containers, containerName)
break
}
return nil
}

// validateContainerName validates storage account container names.
// Reference:
// https://learn.microsoft.com/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata
func validateContainerName(name string) error {
if len(name) < 3 {
return errors.New("name must be 3 characters or more")
}

if strings.Contains(name, "--") {
return errors.New("name cannot contain consecutive hyphens")
}

if strings.ToLower(name) != name {
return errors.New("name must be all lower case")
}

err := names.ValidateLabelName(name)
if err != nil {
return err
}

return nil
}
6 changes: 6 additions & 0 deletions cli/azd/internal/cmd/add/add_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func Metadata(r *project.ResourceConfig) resourceMeta {
res.UseEnvVars = []string{
"AZURE_OPENAI_ENDPOINT",
}
case project.ResourceTypeStorage:
res.AzureResourceType = "Microsoft.Storage/storageAccounts"
res.UseEnvVars = []string{
"AZURE_STORAGE_ACCOUNT_NAME",
"AZURE_STORAGE_BLOB_ENDPOINT",
}
}
return res
}
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/internal/cmd/add/add_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func (a *AddAction) selectMenu() []Menu {
{Namespace: "db", Label: "Database", SelectResource: selectDatabase},
{Namespace: "host", Label: "Host service"},
{Namespace: "ai.openai", Label: "Azure OpenAI", SelectResource: a.selectOpenAi},
{Namespace: "storage", Label: "Storage account", SelectResource: selectStorage},
}
}

Expand Down Expand Up @@ -59,3 +60,13 @@ func selectDatabase(
r.Type = resourceTypesDisplayMap[resourceTypesDisplay[dbOption]]
return r, nil
}

func selectStorage(
console input.Console,
ctx context.Context,
p PromptOptions) (*project.ResourceConfig, error) {
r := &project.ResourceConfig{}
r.Type = project.ResourceTypeStorage
r.Props = project.StorageProps{}
return r, nil
}
2 changes: 1 addition & 1 deletion cli/azd/internal/names/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"strings"
)

var rfc1123LabelRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
var rfc1123LabelRegex = regexp.MustCompile(`^(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])$`)

// ValidateLabelName checks if the given name is a valid RFC 1123 Label name.
func ValidateLabelName(name string) error {
Expand Down
35 changes: 35 additions & 0 deletions cli/azd/internal/names/label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package names

import (
"strings"
"testing"
)

Expand Down Expand Up @@ -68,4 +69,38 @@ func TestLabelNameEdgeCases(t *testing.T) {
}
}

func TestValidateLabelName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"SingleLowercase", "a", false},
{"MaxLength", strings.Repeat("a", 63), false},
{"WithHyphen", "a-b-c", false},
{"EmptyString", "", true},
{"SingleUppercase", "Z", true},
{"TooLong", strings.Repeat("a", 64), true},
{"StartWithUppercase", "Abcdef", true},
{"EndsWithUppercase", "abcdefG", true},
{"InvalidSingleHyphen", "-", true},
{"InvalidSingleSymbol", "!", true},
{"LabelStartingWithHyphen", "-abc", true},
{"LabelEndingWithHyphen", "abc-", true},
{"LabelWithInvalidCharacters", "ab#cd", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLabelName(tt.input)
if tt.wantErr && err == nil {
t.Errorf("expected error for input %q, got none", tt.input)
}
if !tt.wantErr && err != nil {
t.Errorf("expected no error for input %q, got %q", tt.input, err)
}
})
}
}

//cspell:enable
6 changes: 3 additions & 3 deletions cli/azd/internal/repository/app_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ func TestInitializer_prjConfigFromDetect(t *testing.T) {
interactions: []string{
// prompt for db -- hit multiple validation cases
"my app db",
"n",
"N",
"my$special$db",
"n",
"N",
"mongodb", // fill in db name
// prompt for db -- hit multiple validation cases
"my$special$db",
"n",
"N",
"postgres", // fill in db name
},
want: project.ProjectConfig{
Expand Down
4 changes: 3 additions & 1 deletion cli/azd/internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ func TestExecInfra(t *testing.T) {
DbCosmosMongo: &DatabaseCosmosMongo{
DatabaseName: "appdb",
},
DbRedis: &DatabaseRedis{},
DbRedis: &DatabaseRedis{},
StorageAccount: &StorageAccount{},
Services: []ServiceSpec{
{
Name: "api",
Expand All @@ -110,6 +111,7 @@ func TestExecInfra(t *testing.T) {
DbPostgres: &DatabaseReference{
DatabaseName: "appdb",
},
StorageAccount: &StorageReference{},
},
{
Name: "web",
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/internal/scaffold/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type InfraSpec struct {
DbCosmosMongo *DatabaseCosmosMongo
DbRedis *DatabaseRedis

StorageAccount *StorageAccount

// ai models
AIModels []AIModel
}
Expand Down Expand Up @@ -54,6 +56,10 @@ type AIModelModel struct {
Version string
}

type StorageAccount struct {
Containers []string
}

type ServiceSpec struct {
Name string
Port int
Expand All @@ -71,6 +77,8 @@ type ServiceSpec struct {
DbCosmosMongo *DatabaseReference
DbRedis *DatabaseReference

StorageAccount *StorageReference

// AI model connections
AIModels []AIModelReference
}
Expand All @@ -95,6 +103,9 @@ type AIModelReference struct {
Name string
}

type StorageReference struct {
}

func containerAppExistsParameter(serviceName string) Parameter {
return Parameter{
Name: BicepName(serviceName) + "Exists",
Expand Down
19 changes: 19 additions & 0 deletions cli/azd/pkg/project/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func AllResourceTypes() []ResourceType {
ResourceTypeDbMongo,
ResourceTypeHostContainerApp,
ResourceTypeOpenAiModel,
ResourceTypeStorage,
}
}

Expand All @@ -27,6 +28,7 @@ const (
ResourceTypeDbMongo ResourceType = "db.mongo"
ResourceTypeHostContainerApp ResourceType = "host.containerapp"
ResourceTypeOpenAiModel ResourceType = "ai.openai.model"
ResourceTypeStorage ResourceType = "storage"
)

func (r ResourceType) String() string {
Expand All @@ -41,6 +43,8 @@ func (r ResourceType) String() string {
return "Container App"
case ResourceTypeOpenAiModel:
return "Open AI Model"
case ResourceTypeStorage:
return "Storage Account"
}

return ""
Expand Down Expand Up @@ -89,6 +93,11 @@ func (r *ResourceConfig) MarshalYAML() (interface{}, error) {
if err != nil {
return nil, err
}
case ResourceTypeStorage:
err := marshalRawProps(raw.Props.(StorageProps))
if err != nil {
return nil, err
}
}

return raw, nil
Expand Down Expand Up @@ -128,6 +137,12 @@ func (r *ResourceConfig) UnmarshalYAML(value *yaml.Node) error {
return err
}
raw.Props = cap
case ResourceTypeStorage:
sp := StorageProps{}
if err := unmarshalProps(&sp); err != nil {
return err
}
raw.Props = sp
}

*r = ResourceConfig(raw)
Expand Down Expand Up @@ -155,3 +170,7 @@ type AIModelPropsModel struct {
Name string `yaml:"name,omitempty"`
Version string `yaml:"version,omitempty"`
}

type StorageProps struct {
Containers []string `yaml:"containers,omitempty"`
}
10 changes: 10 additions & 0 deletions cli/azd/pkg/project/scaffold_gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) {
Version: props.Model.Version,
},
})
case ResourceTypeStorage:
if infraSpec.StorageAccount != nil {
return nil, fmt.Errorf("only one storage account resource is currently allowed")
}
props := res.Props.(StorageProps)
infraSpec.StorageAccount = &scaffold.StorageAccount{
Containers: props.Containers,
}
}
}

Expand Down Expand Up @@ -273,6 +281,8 @@ func mapHostUses(
backendMapping[use] = res.Name // record the backend -> frontend mapping
case ResourceTypeOpenAiModel:
svcSpec.AIModels = append(svcSpec.AIModels, scaffold.AIModelReference{Name: use})
case ResourceTypeStorage:
svcSpec.StorageAccount = &scaffold.StorageReference{}
}
}

Expand Down
3 changes: 3 additions & 0 deletions cli/azd/resources/scaffold/templates/main.bicept
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@ output AZURE_RESOURCE_REDIS_ID string = resources.outputs.AZURE_RESOURCE_REDIS_I
{{- if .DbPostgres}}
output AZURE_RESOURCE_{{alphaSnakeUpper .DbPostgres.DatabaseName}}_ID string = resources.outputs.AZURE_RESOURCE_{{alphaSnakeUpper .DbPostgres.DatabaseName}}_ID
{{- end}}
{{- if .StorageAccount }}
output AZURE_RESOURCE_STORAGE_ID string = resources.outputs.AZURE_RESOURCE_STORAGE_ID
{{- end}}
{{ end}}
Loading

0 comments on commit f9a9c1f

Please sign in to comment.