| name | a9s-add-resource |
| description | Blueprint for adding a new AWS resource type to a9s — split into CODER steps (1-8) and QA steps (9-13) with templates |
| disable-model-invocation | true |
Adding a New AWS Resource Type
Two agents, two tracks. The architect scopes both tasks. Coder and QA can run in parallel for resource types (pattern is rigid).
Prerequisites
You MUST have a scoped task from the architect with:
- ShortName, Aliases, Display Name, Category
- AWS SDK import, SDK Type, API call
- Pattern: A, B, or C (see below)
- List columns (field keys, titles, widths)
- Detail paths
- FieldKeys list (for
RegisterFieldKeys)
- Exact files to create and modify (with append points)
If you don't have this, STOP. Reply with REJECTED and ask for architect scope.
Agent Ownership
| Steps | Owner | Writes to |
|---|
| 1-8 (implementation) | a9s-coder | internal/, cmd/, .a9s/ |
| 9-13 (tests) | a9s-qa | tests/unit/ |
Coder MUST NOT write test files. QA MUST NOT write production code.
Pattern Variants
Pick the right pattern based on the architect spec:
Decision Tree
- Does this resource need a NEW AWS service client?
- YES -> Pattern A (Simple). Example: Lambda, CloudWatch, IAM
- NO (reuses existing client) -> Pattern B (Client Reuse). Example: VPC, SG reuse EC2; Subnets reuse EC2
- Does fetching require multiple API calls? (list parent -> list children -> describe each)
- YES -> Pattern C (Multi-Step Fetch). Example: Node Groups (ListClusters -> ListNodegroups -> DescribeNodegroup)
Pattern A: Simple (EC2, RDS, S3)
- 1 API call, 1 new interface, new client field in
ServiceClients
- Standard fetcher template (see Checklist step 1)
- Standard single-output mock
Pattern B: Client Reuse (VPC, SG)
- 1 API call, 1 new interface, NO new client field
- Skip step 3 (client.go) entirely
init() passes existing client: e.g., c.EC2 for VPC/SG
- Architect spec includes
ExistingClient: EC2
func init() {
resource.RegisterFieldKeys("vpc", []string{"vpc_id", "name", "state", "cidr", "is_default"})
resource.RegisterPaginated("vpc", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
return FetchVPCsPage(ctx, c.EC2, continuationToken)
})
}
Pattern C: Multi-Step Fetch (Node Groups)
- Multiple API calls, multiple interfaces, fetcher takes multiple API params
init() passes same client multiple times
- Nested loops: list parent -> list children -> describe each
- Architect spec includes
API Sequence: with ordered steps
func init() {
resource.RegisterFieldKeys("ng", []string{"name", "cluster", "status", "instance_types", "desired", "min", "max"})
resource.RegisterPaginated("ng", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
resources, err := FetchNodeGroups(ctx, c.EKS, c.EKS, c.EKS)
if err != nil {
return resource.FetchResult{}, err
}
return resource.FetchResult{
Resources: resources,
Pagination: &resource.PaginationMeta{IsTruncated: false, TotalHint: len(resources), PageSize: len(resources)},
}, nil
})
}
func FetchNodeGroups(
ctx context.Context,
listClustersAPI EKSListClustersAPI,
listNodegroupsAPI EKSListNodegroupsAPI,
describeNodegroupAPI EKSDescribeNodegroupAPI,
) ([]resource.Resource, error) {
}
Pattern C mocks use map-based outputs keyed by parent resource name:
type mockEKSListNodegroupsClient struct {
outputs map[string]*eks.ListNodegroupsOutput
err error
}
type mockEKSDescribeNodegroupClient struct {
outputs map[string]*eks.DescribeNodegroupOutput
err error
}
Common Sub-Patterns
Name from Tags (VPC pattern)
Some resources have no Name field — extract from Tags:
name := ""
for _, tag := range item.Tags {
if tag.Key != nil && *tag.Key == "Name" {
if tag.Value != nil {
name = *tag.Value
}
break
}
}
No Status Field (SG pattern)
Some resources have no status concept. Set Status: "":
r := resource.Resource{
ID: groupID,
Name: groupName,
Status: "",
}
Nil-Guarded Nested Access (Node Groups pattern)
Guard for nil nested structs before accessing fields:
desiredSize := ""
if ng.ScalingConfig != nil {
if ng.ScalingConfig.DesiredSize != nil {
desiredSize = fmt.Sprintf("%d", *ng.ScalingConfig.DesiredSize)
}
}
CODER STEPS (1-8) — a9s-coder agent only
1. Fetcher: core/aws/{type}.go (NEW FILE)
IMPORTANT: Module path is github.com/k2m30/a9s/v3/... (the /v3 suffix is required).
The Resource struct has exactly 5 fields: ID, Name, Status, Fields, RawStruct. There is NO DetailData or RawJSON field. Detail/YAML views use RawStruct + fieldpath.ExtractSubtree for rendering.
package aws
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/{service}"
"github.com/k2m30/a9s/v3/core/resource"
)
func init() {
resource.RegisterFieldKeys("{shortname}", []string{"key1", "key2", ...})
resource.RegisterPaginated("{shortname}", func(ctx context.Context, clients interface{}, continuationToken string) (resource.FetchResult, error) {
c, ok := clients.(*ServiceClients)
if !ok || c == nil {
return resource.FetchResult{}, fmt.Errorf("AWS clients not initialized")
}
return Fetch{TypeName}Page(ctx, c.{ServiceField}, continuationToken)
})
}
func Fetch{TypeName}(ctx context.Context, api {InterfaceName}) ([]resource.Resource, error) {
var all []resource.Resource
token := ""
for {
result, err := Fetch{TypeName}Page(ctx, api, token)
if err != nil { return nil, err }
all = append(all, result.Resources...)
if result.Pagination == nil || !result.Pagination.IsTruncated { break }
token = result.Pagination.NextToken
}
return all, nil
}
func Fetch{TypeName}Page(ctx context.Context, api {InterfaceName}, continuationToken string) (resource.FetchResult, error) {
input := &{service}.{APICall}Input{}
if continuationToken != "" {
input.NextToken = &continuationToken
}
output, err := api.{APICall}(ctx, input)
if err != nil {
return nil, fmt.Errorf("fetching {TypeName}: %w", err)
}
for _, item := range output.{ResultField} {
id :=
name :=
status :=
fields := map[string]string{
"key1": ,
}
resources = append(resources, resource.Resource{
ID: id,
Name: name,
Status: status,
Fields: fields,
RawStruct: item,
})
}
if output.NextToken == nil {
break
}
nextToken = output.NextToken
}
return resources, nil
}
2. Interface: core/aws/<service>_interfaces.go (APPEND to the service's existing file)
type {TypeName}{APICall}API interface {
{APICall}(ctx context.Context, params *{service}.{APICall}Input, optFns ...func(*{service}.Options)) (*{service}.{APICall}Output, error)
}
3. Client field: core/aws/client.go (ADD TO ServiceClients + CreateServiceClients)
If the service is NEW (not already in ServiceClients), add field and constructor:
Add field to ServiceClients struct:
{ServiceField} *{service}.Client
Add constructor line in CreateServiceClients:
{ServiceField}: {service}.NewFromConfig(cfg),
Add import if new service.
If the service already exists (Pattern B), skip this step.
4. Resource type def: core/resource/types_{category}.go (APPEND to category function)
The type definitions are split by category. Append to the correct file:
- Compute:
types_compute.go → computeResourceTypes()
- Containers:
types_containers.go → containersResourceTypes()
- Networking:
types_networking.go → networkingResourceTypes()
- Databases:
types_databases.go → databasesResourceTypes()
- Monitoring:
types_monitoring.go → monitoringResourceTypes()
- Messaging:
types_messaging.go → messagingResourceTypes()
- Secrets:
types_secrets.go → secretsResourceTypes()
- DNS & CDN:
types_dns_cdn.go → dnsCdnResourceTypes()
- Security:
types_security.go → securityResourceTypes()
- CI/CD:
types_cicd.go → cicdResourceTypes()
- Data:
types_data.go → dataResourceTypes()
- Backup:
types_backup.go → backupResourceTypes()
{
Name: "{Display Name}",
ShortName: "{shortname}",
Aliases: []string{"{alias1}", "{alias2}"},
Category: "{CATEGORY}",
Columns: []Column{
{Key: "key1", Title: "Title1", Width: 28, Sortable: true},
},
},
5. Default view config: core/config/defaults_{category}.go (ADD to category function)
The defaults are split by category. Append to the matching defaults_{category}.go file's {category}DefaultViews() map:
"{shortname}": {
List: []ListColumn{
{Title: "Title1", Path: "SDKFieldName", Width: 28},
},
Detail: []string{
"SDKField1", "SDKField2",
},
},
6. User view config: .a9s/views/{shortname}.yaml (REGENERATE via viewsgen)
Run: go run ./cmd/viewsgen/
7. Refgen entry: cmd/refgen/main.go (APPEND to resources slice)
{"{shortname}", "{service}types.{SDKType}", reflect.TypeOf({service}types.{SDKType}{})},
Add import if new service types package.
8. Demo fixtures
Every resource type needs demo mode fixtures. All services use typed fakes — there is no legacy fixture store.
Add fixture data to core/demo/fixtures/<service>.go and extend the matching fake in core/demo/fakes/<service>.go. If the service already has a fake, add new SDK-typed fixture objects to the existing fixture struct and implement any new methods on the fake. If the service is new, create both files following the EC2 pattern.
Wire the new fake into core/demo/client.go (NewServiceClients()) if it's a new service.
Reference: See core/demo/fixtures/ec2.go for the canonical fixture pattern and core/demo/fakes/ec2.go for the fake pattern.
QA STEPS (9-13) — a9s-qa agent only
9. Mock: tests/unit/mocks_test.go (APPEND)
type mock{TypeName}Client struct {
output *{service}.{APICall}Output
err error
}
func (m *mock{TypeName}Client) {APICall}(
ctx context.Context,
params *{service}.{APICall}Input,
optFns ...func(*{service}.Options),
) (*{service}.{APICall}Output, error) {
return m.output, m.err
}
10. Fetcher tests: tests/unit/aws_{shortname}_test.go (NEW FILE)
Write tests covering:
- Happy path: fetcher returns correct resources with expected fields
- Empty response: fetcher returns empty slice, no error
- API error: fetcher returns the error (wrapped with
fmt.Errorf)
- Field extraction: all column keys populated correctly
- RawStruct: original SDK struct preserved
CRITICAL — use exact mock value assertions, NOT == "":
if r.Fields["vpc_id"] == "" {
t.Error("Fields[vpc_id] must not be empty")
}
if r.Fields["vpc_id"] != "vpc-111" {
t.Errorf("Fields[vpc_id] = %q, want %q", r.Fields["vpc_id"], "vpc-111")
}
Every mock sets specific values (e.g., VpcId: aws.String("vpc-111")). The test MUST assert the exact value extracted by the fetcher, not just that it's non-empty. This catches mapping bugs where the wrong field is read.
11. Detail view tests: tests/unit/qa_detail_v220_test.go (APPEND — package unit_test)
Pattern file: tests/unit/qa_detail_v220_test.go (see realisticBackup + TestQA_Detail_Backup_*)
Helpers: tests/unit/helpers_external_test.go (buildResource, configForType, newDetailModel, ensureNoColor)
Add 1 realistic builder + 3 tests per resource type:
func realistic{TypeName}() {service}types.{SDKType} {
return {service}types.{SDKType}{
{Field1}: ptrString("value1"),
{Field2}: ptrString("value2"),
}
}
func TestQA_Detail_{TypeName}_ViewContainsExpectedFields(t *testing.T) {
ensureNoColor(t)
raw := realistic{TypeName}()
res := buildResource("test-id", "test-name", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
view := m.View()
if !strings.Contains(view, "value1") {
t.Errorf("{TypeName} detail should contain {Field1}, got:\n%s", view)
}
}
func TestQA_Detail_{TypeName}_NilFields(t *testing.T) {
ensureNoColor(t)
raw := {service}types.{SDKType}{}
res := buildResource("empty", "empty", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
view := m.View()
if view == "" {
t.Error("detail view should not be empty with nil {TypeName} fields")
}
}
func TestQA_Detail_{TypeName}_FrameTitle(t *testing.T) {
raw := realistic{TypeName}()
res := buildResource("test-id", "test-name", raw)
cfg := configForType("{shortname}")
m := newDetailModel(res, "{shortname}", cfg)
if m.FrameTitle() != "test-name" {
t.Errorf("FrameTitle = %q, want %q", m.FrameTitle(), "test-name")
}
}
Append point: After the last TestQA_Detail_* function in qa_detail_v220_test.go (currently TestQA_Detail_Backup_FrameTitle).
For types without SDK structs (e.g., SQS uses attribute maps):
use buildResourceWithFields and pass nil for RawStruct. The detail view falls back to Fields rendering.
12. YAML view tests: tests/unit/qa_yaml_v220_test.go (APPEND — package unit)
Pattern file: tests/unit/qa_yaml_v220_test.go (see fixtureBackup + TestQA_YAML_Backup_*)
Helpers: yamlView() and yamlModel() from tests/unit/qa_yaml_test.go
Add 1 fixture function + 3 tests per resource type:
func fixture{TypeName}() []resource.Resource {
return []resource.Resource{{
ID: "test-id", Name: "test-name", Status: "active",
Fields: map[string]string{
"key1": "value1",
"key2": "value2",
},
}}
}
func TestQA_YAML_{TypeName}_ViewContainsFields(t *testing.T) {
items := fixture{TypeName}()
for _, item := range items {
out := yamlView(t, item, 120, 40)
for k, v := range item.Fields {
if !strings.Contains(out, k) {
t.Errorf("{TypeName} YAML missing key %q", k)
}
if v != "" && !strings.Contains(out, v) {
t.Errorf("{TypeName} YAML missing value %q", v)
}
}
}
}
func TestQA_YAML_{TypeName}_FrameTitle(t *testing.T) {
items := fixture{TypeName}()
m := yamlModel(items[0], 120, 40)
title := m.FrameTitle()
if !strings.Contains(title, "yaml") {
t.Errorf("FrameTitle() = %q, want 'yaml' in title", title)
}
}
func TestQA_YAML_{TypeName}_RawContentUncolored(t *testing.T) {
items := fixture{TypeName}()
m := yamlModel(items[0], 120, 40)
raw := m.RawContent()
if strings.Contains(raw, "\x1b[") {
t.Error("{TypeName} RawContent() contains ANSI codes")
}
}
Append point: After the last TestQA_YAML_* function in qa_yaml_v220_test.go (currently TestQA_YAML_Backup_RawContentUncolored).
13. List RawStruct test: tests/unit/qa_list_rawstruct_test.go (APPEND — package unit_test)
Pattern file: tests/unit/qa_list_rawstruct_test.go (see TestQA_ListRawStruct_EC2)
Helper: newListModel() from the same file
Add 1 test per resource type (skip types without SDK structs):
func TestQA_ListRawStruct_{TypeName}(t *testing.T) {
ensureNoColor(t)
cfg := configForType("{shortname}")
raw := realistic{TypeName}()
res := resource.Resource{
ID: "test-id", Name: "test-name", Status: "active",
Fields: map[string]string{
"key1": "value1",
},
RawStruct: raw,
}
view := newListModel(t, "{shortname}", cfg, []resource.Resource{res})
if !strings.Contains(view, "value1") {
t.Errorf("{TypeName} list should contain value1 from RawStruct, got:\n%s", view)
}
}
Append point: After the last TestQA_ListRawStruct_* function in qa_list_rawstruct_test.go.
Note: The realistic{TypeName}() function from step 11 is reused here. Both files are in package unit_test, so they share builders.
Why Steps 11-13 Matter
Coverage analysis found these gaps when steps 11-13 were missing:
- 22+ types with zero view-layer tests — detail/YAML rendering was never verified
- NilFields panics go undetected — zero-value SDK structs can crash
fieldpath.ExtractSubtree
- Config-driven paths not exercised — list columns from
defaults_{category}.go might reference non-existent struct fields
- Cross-cutting tests only covered types with explicit test functions — adding RawStruct tests ensures the new type is exercised end-to-end
Post-Implementation Steps (run by whichever agent finishes last)
- Run viewsgen:
go run ./cmd/viewsgen/
- Run refgen:
go run ./cmd/refgen/ > .a9s/views_reference.yaml
- Run tests:
make test
- Run linter:
make lint
- Run gofix:
make gofix
- Build:
make build