diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
index 16f622c..a8132bc 100644
--- a/.github/workflows/deploy.yaml
+++ b/.github/workflows/deploy.yaml
@@ -29,41 +29,9 @@ jobs:
- name: run unit tests
run: go test ./pkg/... -v
- integration:
+ deploy:
needs: test
runs-on: ubuntu-24.04-arm
- permissions:
- contents: read
- id-token: write
- steps:
- - name: checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- ref: ${{ env.RELEVANT_BRANCH }}
-
- - name: setup docker
- uses: ./.github/actions/docker
-
- - name: setup stage
- uses: ./.github/actions/stages
- with:
- stage: build
- account: prod
-
- - name: ecr login
- id: login-ecr
- uses: aws-actions/amazon-ecr-login@v2
-
- - name: build
- run: |
- GIT_BRANCH=${{ env.RELEVANT_BRANCH }} \
- GIT_SHA=${{ env.RELEVANT_SHA }} \
- docker buildx bake echo --push
-
- deployment:
- needs: integration
- runs-on: ubuntu-24.04-arm
permissions:
contents: read
id-token: write
@@ -91,12 +59,13 @@ jobs:
ecr_registry_id: 677771948337
ecr_registry_region: us-west-2
- - name: deploy
+ - name: deploy e2e actor
run: |
go run cmd/monad/main.go deploy \
- --chdir e2e/echo \
- --env .env.tmpl \
- --policy policy.json.tmpl \
+ --service e2e \
+ --image bkeane/actress/src:main \
+ --env e2e/.env.tmpl \
+ --policy e2e/policy.json.tmpl \
--bus default \
--memory 256 \
--disk 1024 \
@@ -109,10 +78,9 @@ jobs:
e2e:
runs-on: ubuntu-24.04-arm
- needs: deployment
+ needs: deploy
env:
TERM: xterm-color
- MONAD_SERVICE: echo
permissions:
contents: read
id-token: write
diff --git a/.github/workflows/destroy.yaml b/.github/workflows/destroy.yaml
index 918e10b..e9cd802 100644
--- a/.github/workflows/destroy.yaml
+++ b/.github/workflows/destroy.yaml
@@ -41,7 +41,7 @@ jobs:
- name: destroy
run: |
go run cmd/monad/main.go \
- --chdir e2e/echo \
+ --chdir e2e \
--branch ${{ env.RELEVANT_BRANCH }} \
destroy
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 15657b6..9e4f778 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -35,12 +35,26 @@ archives:
- goos: windows
formats: [zip]
-brews:
+homebrew_casks:
- name: monad
repository:
owner: bkeane
name: taps
token: "{{ .Env.GITHUB_PERSONAL_AUTH_TOKEN }}"
+ directory: Casks
+ homepage: "https://github.com/bkeane/monad"
+ description: "Monad CLI tool"
+ license: "MIT"
+ binary: monad
+ url:
+ verified: github.com/bkeane/monad
+ commit_author:
+ name: goreleaserbot
+ email: bot@goreleaser.com
+ commit_msg_template: "Brew cask update for {{ .ProjectName }} version {{ .Tag }}"
+ skip_upload: auto
+ test: |
+ system "#{bin}/monad", "--version"
changelog:
sort: asc
diff --git a/cmd/monad/desc/desc.go b/cmd/monad/desc/desc.go
index 238c605..51879c2 100644
--- a/cmd/monad/desc/desc.go
+++ b/cmd/monad/desc/desc.go
@@ -46,3 +46,15 @@ Examples:
Use --owner='*', --repo='*', --branch='*' for unfiltered results (quotes required).`
}
+
+// EcrList returns a description for the ecr list command with filtering information
+func EcrList() string {
+ return `List ECR artifacts filtered by current git context.
+
+Examples:
+ monad ecr list # Current repo/service only
+ monad ecr list --service='*' # All services (note quotes)
+ monad ecr list --owner='*' --repo='*' --service='*' # All artifacts
+
+Use --owner='*', --repo='*', --service='*' for unfiltered results (quotes required).`
+}
diff --git a/cmd/monad/main.go b/cmd/monad/main.go
index 6ac8c23..b04d482 100644
--- a/cmd/monad/main.go
+++ b/cmd/monad/main.go
@@ -163,7 +163,7 @@ func main() {
},
{
Name: "tag",
- Usage: "print basis image",
+ Usage: "print tag",
Action: func(ctx context.Context, cmd *cli.Command) error {
basis, err := pkg.Basis(ctx)
if err != nil {
@@ -179,6 +179,25 @@ func main() {
return nil
},
},
+ {
+ Name: "list",
+ Usage: "list artifacts",
+ Description: desc.EcrList(),
+ Action: func(ctx context.Context, cmd *cli.Command) error {
+ registry, err := pkg.Registry(ctx)
+ if err != nil {
+ return err
+ }
+
+ table, err := registry.Table(ctx)
+ if err != nil {
+ return err
+ }
+
+ fmt.Println(table)
+ return nil
+ },
+ },
},
},
{
diff --git a/docs/vue/src/components/Artifact.vue b/docs/vue/src/components/Artifact.vue
index 3240fb2..b3a76d4 100644
--- a/docs/vue/src/components/Artifact.vue
+++ b/docs/vue/src/components/Artifact.vue
@@ -9,7 +9,7 @@ import basic from '../../assets/diagrams/deployment-basic-1.png'
OCI Images stored on ECR are the deployable artifact of Monad.
If convention is followed these artifacts have an entrypoint of a webserver. So
- even though Monad is a serverless deployment approach, it's standard artifact is compatible
+ even though Monad is a serverless deployment approach, its standard artifact is compatible
with any container runtime capable of managing containerized webservers
AWS Lambda Web Adapter
.
diff --git a/docs/vue/src/components/Event.vue b/docs/vue/src/components/Event.vue
index 60f0b7b..637f6e5 100644
--- a/docs/vue/src/components/Event.vue
+++ b/docs/vue/src/components/Event.vue
@@ -34,7 +34,7 @@ import worker from '../../assets/diagrams/deployment-event-1.png';
Rule
- By default the lambda will be invoked by events matching a unicast pattern...
+ The default rule used provided you have also declared a bus is:
{{`{
"source": [{
@@ -47,17 +47,18 @@ import worker from '../../assets/diagrams/deployment-event-1.png';
}
}`}}
- ... roughly translated:
-
- - source: any service within this repository & branch
- - destination: this service on this branch
-
- The rule flag can be used to customize event matching ruleseventbridge ruleeventBridge sandbox:
+ Which approximates unicast behavior.
+ The rule flag can be used to provide your own custom event matching rule:eventbridge ruleeventbridge sandbox:
{{`monad deploy --bus $bus --rule ./rule.json.tmpl`}}
- Example
- Let's say you wanted broadcast behavior instead of the afformentioned unicast behavior
+ Or rules:
+
+{{`monad deploy --bus $bus --rule ./s3_events.json.tmpl --rule cron.tmpl`}}
+
+ Unicast & Multicast
+ The default rule above is a unicast style rule, with a broadcast domain of the repo & branch.
+ Let's say you wanted multicast behavior instead of the afformentioned unicast behavior:
{{`{
"source": [{
@@ -71,11 +72,6 @@ import worker from '../../assets/diagrams/deployment-event-1.png';
}
`}}
- ... roughly translated:
-
- - source: any service except for itself
- - destination: any service within this repository & branch
-
diff --git a/e2e/echo/.env.tmpl b/e2e/.env.tmpl
similarity index 100%
rename from e2e/echo/.env.tmpl
rename to e2e/.env.tmpl
diff --git a/e2e/echo/.dockerignore b/e2e/echo/.dockerignore
deleted file mode 100644
index b7be024..0000000
--- a/e2e/echo/.dockerignore
+++ /dev/null
@@ -1,89 +0,0 @@
-# Git
-.git
-.gitignore
-.gitattributes
-
-
-# CI
-.codeclimate.yml
-.travis.yml
-.taskcluster.yml
-
-# Docker
-docker-compose.yml
-Dockerfile
-.docker
-.dockerignore
-
-# Byte-compiled / optimized / DLL files
-**/__pycache__/
-**/*.py[cod]
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-env/
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-*.egg-info/
-.installed.cfg
-*.egg
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.coverage
-.cache
-nosetests.xml
-coverage.xml
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-# Virtual environment
-.env
-.venv/
-venv/
-
-# PyCharm
-.idea
-
-# Python mode for VIM
-.ropeproject
-**/.ropeproject
-
-# Vim swap files
-**/*.swp
-
-# VS Code
-.vscode/
\ No newline at end of file
diff --git a/e2e/echo/Dockerfile b/e2e/echo/Dockerfile
deleted file mode 100644
index 2f682d3..0000000
--- a/e2e/echo/Dockerfile
+++ /dev/null
@@ -1,11 +0,0 @@
-FROM python:alpine
-ARG SOURCE_DATE_EPOCH
-COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
-ADD requirements.txt requirements.txt
-RUN pip install --no-cache-dir -r requirements.txt
-COPY /src /src
-ENV PORT=8090
-ENV READINESS_CHECK_PATH=/health
-ENV AWS_LWA_ERROR_STATUS_CODES=500-599
-WORKDIR /src
-ENTRYPOINT ["python", "main.py"]
diff --git a/e2e/echo/requirements.txt b/e2e/echo/requirements.txt
deleted file mode 100644
index 09f7298..0000000
--- a/e2e/echo/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-fastapi
-uvicorn
-boto3
diff --git a/e2e/echo/src/cloudwatch.py b/e2e/echo/src/cloudwatch.py
deleted file mode 100644
index 9039421..0000000
--- a/e2e/echo/src/cloudwatch.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from models import LogEvents
-
-def get_latest_logs(cloudwatch_client, log_group_name: str, n: int, grepv: str = None, grep: str = None) -> LogEvents:
- # Get the list of log streams in the log group, sorted by last event time
- response = cloudwatch_client.describe_log_streams(
- logGroupName=log_group_name,
- orderBy='LastEventTime',
- descending=True,
- limit=50 # Increase limit to get more log streams
- )
-
- log_streams = response.get('logStreams', [])
- if not log_streams:
- return []
-
- all_events = []
- for log_stream in log_streams:
- if len(all_events) >= n:
- break
-
- # Get the log events from the current log stream
- response = cloudwatch_client.get_log_events(
- logGroupName=log_group_name,
- logStreamName=log_stream['logStreamName'],
- limit=n - len(all_events),
- startFromHead=False
- )
-
- validated = LogEvents.model_validate(response, strict=False)
-
- if grepv:
- for event in validated.events:
- if grepv not in event.message:
- all_events.append(event)
- else:
- all_events.extend(validated.events)
-
- if grep:
- all_events = [event for event in all_events if grep in event.message]
-
- return all_events[:n]
-
diff --git a/e2e/echo/src/main.py b/e2e/echo/src/main.py
deleted file mode 100644
index 4fe6ee0..0000000
--- a/e2e/echo/src/main.py
+++ /dev/null
@@ -1,236 +0,0 @@
-import uvicorn
-from boto3 import client
-from os import getenv, environ
-from fastapi import FastAPI, Request, HTTPException
-from pydantic import ValidationError
-from fastapi.responses import PlainTextResponse
-from models import GetFunctionResponse, GetRoleResponse, ListAttachedRolePoliciesResponse, AttachedPolicy, GetPolicyResponse, EventBridgeEvent
-from cloudwatch import get_latest_logs
-import logging
-
-# Configure logging
-logger = logging.getLogger("uvicorn")
-
-app = FastAPI(
- title="Echo",
- description="Echo is an introspection service for assisting e2e tests.",
- version="0.0.1",
- docs_url="/public/docs",
- openapi_url="/public/openapi.json",
- debug=True
-)
-
-@app.middleware("http")
-async def set_root_path(request: Request, call_next):
- if request.headers.get("x-forwarded-prefix"):
- app.root_path = request.headers.get("x-forwarded-prefix")
- response = await call_next(request)
- return response
-
-@app.middleware("http")
-async def log_incoming_events(request: Request, call_next):
- if request.url.path == "/events":
- body = await request.body()
- logger.info("EVENT: %s", body.decode('utf-8'))
-
- response = await call_next(request)
- return response
-
-@app.middleware("http")
-async def configure_boto3(request: Request, call_next):
- request.state.function_name = getenv("AWS_LAMBDA_FUNCTION_NAME")
- request.state.lambdac = client('lambda')
- request.state.iamc = client('iam')
- request.state.logc = client('logs')
- return await call_next(request)
-
-@app.get("/health")
-async def health():
- return {"status": "ok"}
-
-@app.get("/public/health")
-async def public_health():
- return {"status": "ok"}
-
-@app.post("/events")
-async def events(event: EventBridgeEvent):
- logger.info("Received event: %s", event.model_dump_json())
- return {"status": "ok"}
-
-@app.get("/headers")
-async def headers(request: Request):
- return request.headers
-
-@app.get("/headers/{key}", response_class=PlainTextResponse)
-async def header(key: str, request: Request):
- if key not in request.headers.keys():
- raise HTTPException(status_code=404, detail="header not found")
-
- return request.headers.get(key)
-
-@app.get('/env')
-async def env():
- return environ
-
-@app.get("/env/{key}", response_class=PlainTextResponse)
-async def env(key: str):
- if key not in environ.keys():
- raise HTTPException(status_code=404, detail="environment variable not found")
-
- return environ[key]
-
-@app.get("/function")
-async def get_function(request: Request) -> GetFunctionResponse:
- response = request.state.lambdac.get_function(
- FunctionName=request.state.function_name
- )
-
- try:
- validated = GetFunctionResponse.model_validate(response, strict=False)
- except ValidationError as e:
- raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
-
- return validated
-
-@app.get("/function/name", response_class=PlainTextResponse)
-async def get_function_name(request: Request) -> str:
- validated = await get_function(request)
- return validated.Configuration.FunctionName
-
-@app.get("/function/role")
-async def get_function_role(request: Request) -> GetRoleResponse:
- function = await get_function(request)
-
- if function.Configuration.Role is None:
- raise HTTPException(status_code=404, detail="role not found")
-
- role_parts = function.Configuration.Role.split("role/")
- if len(role_parts) != 2:
- raise HTTPException(status_code=422, detail="invalid role ARN format")
-
- response = request.state.iamc.get_role(
- RoleName=role_parts[1]
- )
-
- try:
- validated = GetRoleResponse.model_validate(response, strict=False)
- except ValidationError as e:
- raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
-
- return validated
-
-@app.get("/function/role/name", response_class=PlainTextResponse)
-async def get_function_role_name(request: Request) -> str:
- role = await get_function_role(request)
- return role.Role.RoleName
-
-@app.get("/function/role/tags")
-async def get_function_role_tags(request: Request) -> dict[str, str]:
- role = await get_function_role(request)
- tag_dict = {tag.Key: tag.Value for tag in role.Role.Tags}
- return tag_dict
-
-@app.get("/function/role/tags/{key}", response_class=PlainTextResponse)
-async def get_function_role_tag(request: Request, key: str) -> str:
- tags = await get_function_role_tags(request)
-
- if key not in tags.keys():
- raise HTTPException(status_code=404, detail="tag not found")
-
- return tags[key]
-
-@app.get("/function/role/policies")
-async def get_function_role_policies(request: Request) -> list[AttachedPolicy]:
- role = await get_function_role(request)
- response = request.state.iamc.list_attached_role_policies(
- RoleName=role.Role.RoleName
- )
-
- try:
- validated = ListAttachedRolePoliciesResponse.model_validate(response, strict=False)
- except ValidationError as e:
- raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
-
- return validated.AttachedPolicies
-
-@app.get("/function/role/policies/{policy_name}")
-async def get_function_role_policy(request: Request, policy_name: str) -> GetPolicyResponse:
- policies = await get_function_role_policies(request)
-
- for policy in policies:
- if policy.PolicyName == policy_name:
- response = request.state.iamc.get_policy(
- PolicyArn=policy.PolicyArn
- )
-
- try:
- validated = GetPolicyResponse.model_validate(response, strict=False)
- except ValidationError as e:
- raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
- except Exception as e:
- raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
-
- return validated
-
- raise HTTPException(status_code=404, detail="policy not found")
-
-@app.get("/function/role/policies/{policy_name}/tags")
-async def get_function_role_policy_tags(request: Request, policy_name: str) -> dict[str, str]:
- policy = await get_function_role_policy(request, policy_name)
- tag_dict = {tag.Key: tag.Value for tag in policy.Policy.Tags}
- return tag_dict
-
-@app.get("/function/tags")
-async def get_function_tags(request: Request) -> dict[str, str]:
- validated = await get_function(request)
- return validated.Tags
-
-@app.get("/function/tags/{key}", response_class=PlainTextResponse)
-async def get_function_tag(request: Request, key: str) -> str:
- validated = await get_function_tags(request)
-
- if key not in validated.keys():
- raise HTTPException(status_code=404, detail="tag not found")
-
- return validated[key]
-
-@app.get("/function/memory", response_class=PlainTextResponse)
-async def get_function_configuration_memory(request: Request) -> str:
- validated = await get_function(request)
- return str(validated.Configuration.MemorySize)
-
-@app.get("/function/timeout", response_class=PlainTextResponse)
-async def get_function_configuration_timeout(request: Request) -> str:
- validated = await get_function(request)
- return str(validated.Configuration.Timeout)
-
-@app.get("/function/disk", response_class=PlainTextResponse)
-async def get_function_configuration_disk(request: Request) -> str:
- validated = await get_function(request)
- return str(validated.Configuration.EphemeralStorage.Size)
-
-@app.get("/function/log_group", response_class=PlainTextResponse)
-async def get_function_configuration_log_group(request: Request) -> str:
- validated = await get_function(request)
- return validated.Configuration.LoggingConfig.LogGroup
-
-@app.get("/function/log_group/tail")
-async def tail_function_log_group(request: Request, n: int = 10, grep: str = None, expect: bool = False) -> list[str]:
- log_group = await get_function_configuration_log_group(request)
- logs = get_latest_logs(request.state.logc, log_group, n, request.url.path, grep)
-
- # If expect is true, return a 404 if no logs are found
- if expect:
- if len(logs) == 0:
- raise HTTPException(status_code=404, detail="log not found")
-
- return [log.message for log in logs]
-
-if __name__ == '__main__':
- uvicorn.run(app, host="0.0.0.0", port=int(getenv("PORT","8090")))
diff --git a/e2e/echo/src/models.py b/e2e/echo/src/models.py
deleted file mode 100644
index 96be399..0000000
--- a/e2e/echo/src/models.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from pydantic import BaseModel
-from enum import Enum
-from typing import Any
-
-class EphemeralStorage(BaseModel):
- Size: int
-
-class VpcConfig(BaseModel):
- SecurityGroups: list[str]
- Subnets: list[str]
-
-class Code(BaseModel):
- ImageUri: str
-
-class Architecture(str, Enum):
- x86_64 = "x86_64"
- arm64 = "arm64"
-
-class LoggingConfig(BaseModel):
- LogFormat: str
- LogGroup: str
-
-class Configuration(BaseModel):
- FunctionName: str
- Timeout: int
- MemorySize: int
- EphemeralStorage: EphemeralStorage
- Role: str
- LoggingConfig: LoggingConfig
-
-class GetFunctionResponse(BaseModel):
- Configuration: Configuration
- Tags: dict[str, str]
-
-class Tag(BaseModel):
- Key: str
- Value: str
-
-class Role(BaseModel):
- RoleName: str
- Tags: list[Tag]
-
-class GetRoleResponse(BaseModel):
- Role: Role
-
-class AttachedPolicy(BaseModel):
- PolicyName: str
- PolicyArn: str
-
-class ListAttachedRolePoliciesResponse(BaseModel):
- AttachedPolicies: list[AttachedPolicy]
-
-class Policy(BaseModel):
- PolicyName: str
- Arn: str
- Tags: list[Tag]
-
-class GetPolicyResponse(BaseModel):
- Policy: Policy
-
-class EventBridgeEvent(BaseModel):
- Version: str
- Id: str
- DetailType: str
- Source: str
- Account: str
- Time: str
- Region: str
- Resources: list[str]
- Detail: Any
-
-class LogEvent(BaseModel):
- message: str
- timestamp: int
- ingestionTime: int
-
-class LogEvents(BaseModel):
- events: list[LogEvent]
-
diff --git a/e2e/echo/policy.json.tmpl b/e2e/policy.json.tmpl
similarity index 100%
rename from e2e/echo/policy.json.tmpl
rename to e2e/policy.json.tmpl
diff --git a/e2e/spec/spec_helper.sh b/e2e/spec/spec_helper.sh
index 582ba59..22efaf7 100644
--- a/e2e/spec/spec_helper.sh
+++ b/e2e/spec/spec_helper.sh
@@ -17,6 +17,7 @@ spec_helper_precheck() {
setenv MONAD_REPO=${MONAD_REPO:=monad}
setenv MONAD_BRANCH=${MONAD_BRANCH:=$(git rev-parse --abbrev-ref HEAD)}
setenv MONAD_SHA=${MONAD_SHA:=$(git rev-parse HEAD)}
+ setenv MONAD_SERVICE=${MONAD_SERVICE:=e2e}
setenv MONAD_API=${MONAD_API:=kaixo}
setenv MONAD_HOST=${MONAD_HOST:=$(resolve_api_domain $MONAD_API)}
diff --git a/e2e/terraform/prod/main.tf b/e2e/terraform/prod/main.tf
index b47279e..12d084e 100644
--- a/e2e/terraform/prod/main.tf
+++ b/e2e/terraform/prod/main.tf
@@ -34,8 +34,8 @@ module "api_gateway" {
}
}
-resource "aws_ecr_repository" "echo" {
- name = "bkeane/monad/echo"
+data "aws_ecr_repository" "actress" {
+ name = "bkeane/actress/src"
}
module "topology" {
@@ -49,7 +49,7 @@ module "topology" {
}
ecr_repositories = [
- aws_ecr_repository.echo,
+ data.aws_ecr_repository.actress
]
stages = [
diff --git a/go.mod b/go.mod
index ca5c46e..3fb8c7f 100644
--- a/go.mod
+++ b/go.mod
@@ -47,6 +47,7 @@ require (
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.3.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
diff --git a/go.sum b/go.sum
index 4828819..f587ed0 100644
--- a/go.sum
+++ b/go.sum
@@ -81,6 +81,8 @@ github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM=
github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
diff --git a/pkg/config/ecr/ecr.go b/pkg/config/ecr/ecr.go
index f084076..db4a0a8 100644
--- a/pkg/config/ecr/ecr.go
+++ b/pkg/config/ecr/ecr.go
@@ -6,7 +6,9 @@ import (
"github.com/bkeane/monad/internal/registryv2"
"github.com/bkeane/monad/pkg/basis/caller"
+ "github.com/bkeane/monad/pkg/basis/git"
"github.com/bkeane/monad/pkg/basis/registry"
+ "github.com/bkeane/monad/pkg/basis/service"
"github.com/aws/aws-sdk-go-v2/service/ecr"
v "github.com/go-ozzo/ozzo-validation/v4"
@@ -15,6 +17,8 @@ import (
type Basis interface {
Caller() (*caller.Basis, error)
Registry() (*registry.Basis, error)
+ Git() (*git.Basis, error)
+ Service() (*service.Basis, error)
}
//
@@ -26,6 +30,8 @@ type Config struct {
registryv2 *registryv2.Client
caller *caller.Basis
registry *registry.Basis
+ git *git.Basis
+ service *service.Basis
}
//
@@ -46,6 +52,16 @@ func Derive(ctx context.Context, basis Basis) (*Config, error) {
return nil, err
}
+ cfg.git, err = basis.Git()
+ if err != nil {
+ return nil, err
+ }
+
+ cfg.service, err = basis.Service()
+ if err != nil {
+ return nil, err
+ }
+
cfg.client = ecr.NewFromConfig(cfg.caller.AwsConfig())
cfg.registryv2, err = registryv2.InitEcr(ctx, cfg.caller.AwsConfig(), cfg.registry.Id(), cfg.registry.Region())
@@ -96,3 +112,13 @@ func (c *Config) ImageTag() string {
func (c *Config) RegistryId() string {
return c.registry.Id()
}
+
+// Git returns the git basis
+func (c *Config) Git() *git.Basis {
+ return c.git
+}
+
+// Service returns the service basis
+func (c *Config) Service() *service.Basis {
+ return c.service
+}
diff --git a/pkg/config/eventbridge/eventbridge.go b/pkg/config/eventbridge/eventbridge.go
index 5a3cbce..3431a07 100644
--- a/pkg/config/eventbridge/eventbridge.go
+++ b/pkg/config/eventbridge/eventbridge.go
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"os"
+ "path/filepath"
"slices"
"strings"
+ "unicode"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/eventbridge"
@@ -34,15 +36,47 @@ type Config struct {
client *eventbridge.Client
EventBridgeRegionName string `env:"MONAD_BUS_REGION"`
EventBridgeBusName string `env:"MONAD_BUS_NAME" flag:"--bus" usage:"EventBridge bus name" hint:"name"`
- EventBridgeRuleName string
- EventBridgeRulePath string `env:"MONAD_RULE" flag:"--rule" usage:"EventBridge rule template file path" hint:"path"`
- EventBridgeRuleTemplate string
- EventBridgeRuleDocument string
+ EventBridgeRulePaths []string `env:"MONAD_RULE" flag:"--rule" usage:"EventBridge rule template file paths" hint:"path"`
+ EventBridgeRulesMap map[string]string
caller *caller.Basis
defaults *defaults.Basis
resource *resource.Basis
}
+//
+// Helper functions
+//
+
+// extractRuleName converts filename to rule name by removing all extensions
+// e.g., "s3.json.tmpl" -> "s3", "schedule.yaml" -> "schedule"
+func extractRuleName(filePath string) string {
+ filename := filepath.Base(filePath)
+ // Keep removing extensions until no more dots
+ for strings.Contains(filename, ".") {
+ filename = strings.TrimSuffix(filename, filepath.Ext(filename))
+ }
+ return filename
+}
+
+// chomp removes leading and trailing whitespace
+func chomp(s string) string {
+ s = strings.TrimLeftFunc(s, unicode.IsSpace)
+ s = strings.TrimRightFunc(s, unicode.IsSpace)
+ return s
+}
+
+// processRuleContent applies chomping to schedule expressions
+func processRuleContent(content string) string {
+ // First chomp to check the prefix without leading whitespace
+ chomped := chomp(content)
+ // If it's a schedule expression, return the chomped version
+ if strings.HasPrefix(chomped, "cron(") || strings.HasPrefix(chomped, "rate(") {
+ return chomped
+ }
+ // Leave event patterns as-is (JSON should not be chomped)
+ return content
+}
+
//
// Derive
//
@@ -77,26 +111,41 @@ func Derive(ctx context.Context, basis Basis) (*Config, error) {
cfg.EventBridgeRegionName = cfg.caller.AwsConfig().Region
}
- if cfg.EventBridgeRuleName == "" {
- cfg.EventBridgeRuleName = cfg.resource.Name()
- }
-
- if cfg.EventBridgeRulePath == "" {
- cfg.EventBridgeRuleTemplate = cfg.defaults.RuleTemplate()
+ cfg.EventBridgeRulesMap = make(map[string]string)
- } else {
- bytes, err := os.ReadFile(cfg.EventBridgeRulePath)
+ if len(cfg.EventBridgeRulePaths) == 0 {
+ // Use default rule
+ defaultTemplate := cfg.defaults.RuleTemplate()
+ defaultDocument, err := basis.Render(defaultTemplate)
if err != nil {
return nil, err
}
-
- cfg.EventBridgeRuleTemplate = string(bytes)
-
- }
-
- cfg.EventBridgeRuleDocument, err = basis.Render(cfg.EventBridgeRuleTemplate)
- if err != nil {
- return nil, err
+ cfg.EventBridgeRulesMap[cfg.resource.Name()] = processRuleContent(defaultDocument)
+ } else {
+ // Process multiple rule files with duplicate name detection
+ for _, path := range cfg.EventBridgeRulePaths {
+ baseRuleName := extractRuleName(path)
+ // Prefix with resource name to avoid cross-repo/branch collisions
+ ruleName := fmt.Sprintf("%s-%s", cfg.resource.Name(), baseRuleName)
+
+ // Check for duplicate names
+ if _, exists := cfg.EventBridgeRulesMap[ruleName]; exists {
+ return nil, fmt.Errorf("duplicate rule name '%s' derived from file '%s'", ruleName, path)
+ }
+
+ bytes, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ template := string(bytes)
+ document, err := basis.Render(template)
+ if err != nil {
+ return nil, err
+ }
+
+ cfg.EventBridgeRulesMap[ruleName] = processRuleContent(document)
+ }
}
if err = cfg.Validate(); err != nil {
@@ -114,9 +163,9 @@ func (c *Config) Validate() error {
return v.ValidateStruct(c,
v.Field(&c.client, v.Required),
v.Field(&c.EventBridgeBusName, v.By(c.emptyOrExists)),
- v.Field(&c.EventBridgeRuleName, v.Required),
- v.Field(&c.EventBridgeRuleTemplate, v.Required),
- v.Field(&c.EventBridgeRuleDocument, v.Required),
+ v.Field(&c.EventBridgeRulesMap,
+ v.By(c.validateRulesMap),
+ ),
v.Field(&c.EventBridgeRegionName, v.Required),
)
}
@@ -156,6 +205,22 @@ func (c *Config) emptyOrExists(value interface{}) error {
return nil
}
+func (c *Config) validateRulesMap(value interface{}) error {
+ rules, ok := value.(map[string]string)
+ if !ok {
+ return fmt.Errorf("rules must be a map[string]string")
+ }
+ for name, content := range rules {
+ if name == "" {
+ return fmt.Errorf("rule name cannot be empty")
+ }
+ if content == "" {
+ return fmt.Errorf("rule content cannot be empty for rule '%s'", name)
+ }
+ }
+ return nil
+}
+
//
// Accessors
//
@@ -171,19 +236,16 @@ func (c *Config) BusName() string {
return c.EventBridgeBusName
}
-// RuleTemplate returns the EventBridge rule name
-func (c *Config) RuleName() string { return c.EventBridgeRuleName }
+// Rules returns all configured EventBridge rules as name->content map
+func (c *Config) Rules() map[string]string {
+ return c.EventBridgeRulesMap
+}
// PermissionStatementId returns the Lambda permission statement ID for EventBridge
func (c *Config) PermissionStatementId() string {
return strings.Join([]string{"eventbridge", c.BusName(), c.resource.Name()}, "-")
}
-// RuleDocument returns the eventbridge rule definition
-func (c *Config) RuleDocument() string {
- return c.EventBridgeRuleDocument
-}
-
// Tags returns standardized EventBridge resource tags
func (c *Config) Tags() []eventbridgetypes.Tag {
var tags []eventbridgetypes.Tag
diff --git a/pkg/config/eventbridge/eventbridge_test.go b/pkg/config/eventbridge/eventbridge_test.go
index c53c441..eb251c8 100644
--- a/pkg/config/eventbridge/eventbridge_test.go
+++ b/pkg/config/eventbridge/eventbridge_test.go
@@ -32,7 +32,10 @@ func TestDerive_Success(t *testing.T) {
assert.NotNil(t, config.Client())
assert.Equal(t, "us-east-1", config.Region())
assert.Equal(t, "", config.BusName()) // No longer defaults to "default"
- assert.Equal(t, "test-repo-test-branch-test-service", config.RuleName())
+ // Test default rule is created with resource name
+ rules := config.Rules()
+ assert.Len(t, rules, 1)
+ assert.Contains(t, rules, "test-repo-test-branch-test-service")
}
func TestDerive_DefaultValues(t *testing.T) {
@@ -50,7 +53,10 @@ func TestDerive_DefaultValues(t *testing.T) {
// Test default values are applied
assert.Equal(t, "us-east-1", config.Region()) // From caller
assert.Equal(t, "", config.BusName()) // Empty when not set
- assert.Equal(t, "test-repo-test-branch-test-service", config.RuleName()) // From resource
+ // Test default rule is created with resource name
+ rules := config.Rules()
+ assert.Len(t, rules, 1)
+ assert.Contains(t, rules, "test-repo-test-branch-test-service")
}
func TestDerive_CustomValues(t *testing.T) {
@@ -101,7 +107,12 @@ func TestDerive_WithCustomRuleTemplate(t *testing.T) {
return
}
- document := config.RuleDocument()
+ rules := config.Rules()
+ assert.Len(t, rules, 1)
+ // The rule name should be prefixed with resource name and extracted from the filename
+ expectedRuleName := "test-repo-test-branch-test-service-custom-rule"
+ assert.Contains(t, rules, expectedRuleName)
+ document := rules[expectedRuleName]
assert.Contains(t, document, "test-service-rule")
assert.Contains(t, document, `"source": ["test-service"]`)
}
@@ -131,7 +142,10 @@ func TestDerive_WithCustomBusAndRule(t *testing.T) {
assert.Equal(t, "eu-west-1", config.Region())
assert.Equal(t, "custom-event-bus", config.BusName())
- assert.Equal(t, "custom-repo-custom-branch-custom-service", config.RuleName())
+ // Test default rule is created with resource name
+ rules := config.Rules()
+ assert.Len(t, rules, 1)
+ assert.Contains(t, rules, "custom-repo-custom-branch-custom-service")
}
func TestDerive_Tags(t *testing.T) {
@@ -317,7 +331,13 @@ func TestRuleDocument_Default(t *testing.T) {
}
// When no rule template path is provided, should use default rule template
- document := config.RuleDocument()
+ rules := config.Rules()
+ assert.Len(t, rules, 1)
+ // Get the single default rule document
+ var document string
+ for _, doc := range rules {
+ document = doc
+ }
// The document will be rendered from the default rule template, so it should contain expected structure
assert.NotEmpty(t, strings.TrimSpace(document))
assert.Contains(t, document, "source")
@@ -369,4 +389,231 @@ func TestBusName_Formatting(t *testing.T) {
assert.Equal(t, tt.expectedBus, config.BusName())
})
}
+}
+
+func TestExtractRuleName(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ expected string
+ }{
+ {
+ name: "single extension",
+ filePath: "rule.json",
+ expected: "rule",
+ },
+ {
+ name: "multiple extensions",
+ filePath: "s3.json.tmpl",
+ expected: "s3",
+ },
+ {
+ name: "many extensions",
+ filePath: "schedule.yaml.template.backup",
+ expected: "schedule",
+ },
+ {
+ name: "path with directory",
+ filePath: "/path/to/rules/my-rule.json",
+ expected: "my-rule",
+ },
+ {
+ name: "path with multiple dots in directory",
+ filePath: "/path.to/rules/event.pattern.json.tmpl",
+ expected: "event",
+ },
+ {
+ name: "no extension",
+ filePath: "rulename",
+ expected: "rulename",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := extractRuleName(tt.filePath)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestDerive_MultipleRules(t *testing.T) {
+ setup := mock.NewTestSetup()
+
+ // Create multiple temp rule files
+ tmpDir := t.TempDir()
+
+ // First rule file
+ s3Rule := `{
+ "source": ["aws.s3"],
+ "detail-type": ["Object Created"]
+ }`
+ s3File := tmpDir + "/s3.json.tmpl"
+ require.NoError(t, os.WriteFile(s3File, []byte(s3Rule), 0644))
+
+ // Second rule file
+ scheduleRule := `rate(5 minutes)`
+ scheduleFile := tmpDir + "/schedule.yaml"
+ require.NoError(t, os.WriteFile(scheduleFile, []byte(scheduleRule), 0644))
+
+ // Apply with multiple rule files
+ setup.ApplyWithOverrides(t, map[string]string{
+ "MONAD_RULE": s3File + "," + scheduleFile,
+ })
+ ctx := context.Background()
+
+ config, err := Derive(ctx, setup.Basis)
+ if err != nil {
+ // Should be an AWS-related error, not a configuration error
+ assert.NotContains(t, err.Error(), "mock:")
+ return
+ }
+
+ rules := config.Rules()
+ assert.Len(t, rules, 2)
+
+ // Check both rules exist with correct names (prefixed with resource name)
+ s3RuleName := "test-repo-test-branch-test-service-s3"
+ scheduleRuleName := "test-repo-test-branch-test-service-schedule"
+ assert.Contains(t, rules, s3RuleName)
+ assert.Contains(t, rules, scheduleRuleName)
+
+ // Check rule content
+ assert.Contains(t, rules[s3RuleName], "aws.s3")
+ assert.Contains(t, rules[scheduleRuleName], "rate(5 minutes)")
+}
+
+func TestDerive_DuplicateRuleNames(t *testing.T) {
+ setup := mock.NewTestSetup()
+
+ // Create multiple temp rule files with same base name
+ tmpDir := t.TempDir()
+
+ // First rule file
+ rule1 := `{"source": ["test1"]}`
+ file1 := tmpDir + "/rule.json"
+ require.NoError(t, os.WriteFile(file1, []byte(rule1), 0644))
+
+ // Second rule file with different extension but same base name
+ rule2 := `{"source": ["test2"]}`
+ file2 := tmpDir + "/rule.yaml"
+ require.NoError(t, os.WriteFile(file2, []byte(rule2), 0644))
+
+ // Apply with multiple rule files that would create duplicate names
+ setup.ApplyWithOverrides(t, map[string]string{
+ "MONAD_RULE": file1 + "," + file2,
+ })
+ ctx := context.Background()
+
+ _, err := Derive(ctx, setup.Basis)
+ assert.Error(t, err)
+ // The error should include the prefixed rule name
+ assert.Contains(t, err.Error(), "duplicate rule name 'test-repo-test-branch-test-service-rule'")
+}
+
+func TestProcessRuleContent(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "cron expression with whitespace",
+ input: " cron(0 12 * * ? *) ",
+ expected: "cron(0 12 * * ? *)",
+ },
+ {
+ name: "rate expression with whitespace",
+ input: "\t\nrate(5 minutes)\n\t",
+ expected: "rate(5 minutes)",
+ },
+ {
+ name: "cron expression without whitespace",
+ input: "cron(0 12 * * ? *)",
+ expected: "cron(0 12 * * ? *)",
+ },
+ {
+ name: "rate expression without whitespace",
+ input: "rate(5 minutes)",
+ expected: "rate(5 minutes)",
+ },
+ {
+ name: "JSON event pattern with whitespace (not chomped)",
+ input: " {\"source\": [\"aws.s3\"]} ",
+ expected: " {\"source\": [\"aws.s3\"]} ",
+ },
+ {
+ name: "JSON event pattern without whitespace",
+ input: "{\"source\": [\"aws.s3\"]}",
+ expected: "{\"source\": [\"aws.s3\"]}",
+ },
+ {
+ name: "complex JSON with newlines (not chomped)",
+ input: "{\n \"source\": [\"aws.s3\"],\n \"detail\": {}\n}",
+ expected: "{\n \"source\": [\"aws.s3\"],\n \"detail\": {}\n}",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := processRuleContent(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestRulesMap_Validation(t *testing.T) {
+ tests := []struct {
+ name string
+ rulesMap map[string]string
+ expectErr bool
+ errMsg string
+ }{
+ {
+ name: "valid rules",
+ rulesMap: map[string]string{"rule1": "content1", "rule2": "content2"},
+ expectErr: false,
+ },
+ {
+ name: "empty map is valid (no rules configured)",
+ rulesMap: map[string]string{},
+ expectErr: false,
+ },
+ {
+ name: "nil map is valid (no rules configured)",
+ rulesMap: nil,
+ expectErr: false,
+ },
+ {
+ name: "empty rule name",
+ rulesMap: map[string]string{"": "content"},
+ expectErr: true,
+ errMsg: "rule name cannot be empty",
+ },
+ {
+ name: "empty rule content",
+ rulesMap: map[string]string{"rule1": ""},
+ expectErr: true,
+ errMsg: "rule content cannot be empty",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ config := &Config{
+ EventBridgeRulesMap: tt.rulesMap,
+ }
+
+ err := config.validateRulesMap(config.EventBridgeRulesMap)
+
+ if tt.expectErr {
+ assert.Error(t, err)
+ if tt.errMsg != "" {
+ assert.Contains(t, err.Error(), tt.errMsg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
}
\ No newline at end of file
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index 9133c6e..8bf215f 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -7,13 +7,20 @@ import (
"fmt"
"os"
"os/exec"
+ "sort"
"strings"
+ "time"
"github.com/bkeane/monad/internal/registryv2"
+ "github.com/bkeane/monad/pkg/basis/git"
+ "github.com/bkeane/monad/pkg/basis/service"
+ "github.com/dustin/go-humanize"
"github.com/rs/zerolog/log"
+ "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/aws/smithy-go"
+ "github.com/charmbracelet/lipgloss/table"
)
type EcrConfig interface {
@@ -21,6 +28,8 @@ type EcrConfig interface {
ImagePath() string
ImageTag() string
RegistryId() string
+ Git() *git.Basis
+ Service() *service.Basis
}
type ImageRegistry interface {
@@ -178,4 +187,267 @@ func parseToken(token *string) (username string, password string, err error) {
}
return parts[0], parts[1], nil
+}
+
+//
+// ArtifactMetadata
+//
+
+type ArtifactMetadata struct {
+ Repository string
+ Tag string
+ Digest string
+ Size int64
+ PushedAt *time.Time
+ Owner string
+ Repo string
+ Service string
+}
+
+//
+// List
+//
+
+func (c *Client) List(ctx context.Context) ([]*ArtifactMetadata, error) {
+ var artifacts []*ArtifactMetadata
+ var nextToken *string
+
+ // Paginate through all repositories
+ for {
+ input := &ecr.DescribeRepositoriesInput{
+ MaxResults: aws.Int32(100),
+ NextToken: nextToken,
+ }
+
+ repos, err := c.ecr.DescribeRepositories(ctx, input)
+ if err != nil {
+ return nil, fmt.Errorf("failed to describe repositories: %w", err)
+ }
+
+
+ // For each repository, list its images
+ for _, repo := range repos.Repositories {
+ repoName := aws.ToString(repo.RepositoryName)
+
+ // Parse repository name to extract owner/repo/service
+ owner, repoField, serviceName := c.parseRepositoryName(repoName)
+
+ // Apply filtering
+ metadata := &ArtifactMetadata{
+ Repository: repoName,
+ Owner: owner,
+ Repo: repoField,
+ Service: serviceName,
+ }
+
+ if !c.matchesFilter(metadata) {
+ continue
+ }
+
+ // Get detailed image information for this repository with pagination
+ var imageNextToken *string
+ totalTaggedCount := 0
+ totalUntaggedCount := 0
+
+ for {
+ detailInput := &ecr.DescribeImagesInput{
+ RepositoryName: aws.String(repoName),
+ MaxResults: aws.Int32(100),
+ NextToken: imageNextToken,
+ }
+
+ details, err := c.ecr.DescribeImages(ctx, detailInput)
+ if err != nil {
+ log.Warn().
+ Str("repository", repoName).
+ Err(err).
+ Msg("failed to describe images for repository")
+ break
+ }
+
+ // Create artifact metadata for each image detail in this batch
+ taggedCount := 0
+ untaggedCount := 0
+ for _, detail := range details.ImageDetails {
+ // Skip images without tags
+ if detail.ImageTags == nil || len(detail.ImageTags) == 0 {
+ untaggedCount++
+ continue
+ }
+
+ taggedCount++
+
+ // Create an artifact for each tag (some images have multiple tags)
+ for _, tag := range detail.ImageTags {
+ artifact := &ArtifactMetadata{
+ Repository: repoName,
+ Owner: owner,
+ Repo: repoField,
+ Service: serviceName,
+ Tag: tag,
+ }
+
+ if detail.ImageDigest != nil {
+ artifact.Digest = aws.ToString(detail.ImageDigest)
+ }
+
+ if detail.ImageSizeInBytes != nil {
+ artifact.Size = aws.ToInt64(detail.ImageSizeInBytes)
+ }
+
+ if detail.ImagePushedAt != nil {
+ artifact.PushedAt = detail.ImagePushedAt
+ }
+
+ artifacts = append(artifacts, artifact)
+ }
+ }
+
+ totalTaggedCount += taggedCount
+ totalUntaggedCount += untaggedCount
+
+ // Check if there are more images to fetch
+ imageNextToken = details.NextToken
+ if imageNextToken == nil {
+ break
+ }
+ }
+ }
+
+ // Check if there are more repositories to fetch
+ nextToken = repos.NextToken
+ if nextToken == nil {
+ break
+ }
+ }
+
+ return artifacts, nil
+}
+
+func (c *Client) Table(ctx context.Context) (string, error) {
+ artifacts, err := c.List(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ // Sort artifacts by repository name, then by push date (newest first)
+ sort.Slice(artifacts, func(i, j int) bool {
+ if artifacts[i].Repository != artifacts[j].Repository {
+ return artifacts[i].Repository < artifacts[j].Repository
+ }
+
+ // Within same repository, sort by push date (newest first)
+ if artifacts[i].PushedAt != nil && artifacts[j].PushedAt != nil {
+ return artifacts[i].PushedAt.After(*artifacts[j].PushedAt)
+ }
+ if artifacts[i].PushedAt != nil && artifacts[j].PushedAt == nil {
+ return true
+ }
+ if artifacts[i].PushedAt == nil && artifacts[j].PushedAt != nil {
+ return false
+ }
+
+ // Fall back to tag comparison
+ return artifacts[i].Tag < artifacts[j].Tag
+ })
+
+ tbl := table.New()
+ tbl.Headers("Repository", "Tag", "Digest", "Size", "Released")
+
+ for _, artifact := range artifacts {
+ digestDisplay := truncateDigest(artifact.Digest)
+ sizeDisplay := formatSize(artifact.Size)
+ pushedDisplay := formatTime(artifact.PushedAt)
+
+ tbl.Row(artifact.Repository, artifact.Tag, digestDisplay, sizeDisplay, pushedDisplay)
+ }
+
+ return tbl.Render(), nil
+}
+
+//
+// Helpers
+//
+
+// parseRepositoryName extracts owner, repo, and service from repository name
+// Assumes format: owner/repo/service or owner/repo
+func (c *Client) parseRepositoryName(repoName string) (owner, repo, service string) {
+ parts := strings.Split(repoName, "/")
+
+ if len(parts) >= 2 {
+ owner = parts[0]
+ repo = parts[1]
+ }
+
+ if len(parts) >= 3 {
+ service = parts[2]
+ }
+
+ return owner, repo, service
+}
+
+// matchesFilter checks if artifact metadata matches the basis filter values
+// * means match all for that field
+func (c *Client) matchesFilter(metadata *ArtifactMetadata) bool {
+ gitBasis := c.config.Git()
+ serviceBasis := c.config.Service()
+
+ // Check owner filter
+ if gitBasis.Owner() != "*" && gitBasis.Owner() != metadata.Owner {
+ return false
+ }
+
+ // Check repo filter
+ if gitBasis.Repo() != "*" && gitBasis.Repo() != metadata.Repo {
+ return false
+ }
+
+ // Check service filter - for ECR list, default to "*" to show all services
+ serviceFilter := "*" // Default to showing all services for ECR list
+
+ // Override if service filter was explicitly provided
+ if serviceBasis.Name() != "" {
+ // Check if the service name was explicitly set via --service flag
+ // vs defaulted from directory name by checking environment variable
+ if os.Getenv("MONAD_SERVICE") != "" {
+ serviceFilter = serviceBasis.Name()
+ }
+ }
+
+ if serviceFilter != "*" && serviceFilter != metadata.Service {
+ return false
+ }
+
+ return true
+}
+
+// truncateDigest shortens digest to 12 characters for display
+func truncateDigest(digest string) string {
+ if len(digest) <= 12 {
+ return digest
+ }
+ // Skip sha256: prefix if present
+ if strings.HasPrefix(digest, "sha256:") {
+ digest = digest[7:]
+ }
+ if len(digest) > 12 {
+ return digest[:12]
+ }
+ return digest
+}
+
+// formatSize converts bytes to human-readable format
+func formatSize(size int64) string {
+ if size == 0 {
+ return "-"
+ }
+ return humanize.Bytes(uint64(size))
+}
+
+// formatTime formats time for display using human-friendly format
+func formatTime(t *time.Time) string {
+ if t == nil {
+ return "-"
+ }
+ return humanize.Time(*t)
}
\ No newline at end of file
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
index 456a3cb..40de75d 100644
--- a/pkg/registry/registry_test.go
+++ b/pkg/registry/registry_test.go
@@ -6,6 +6,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/bkeane/monad/internal/registryv2"
+ "github.com/bkeane/monad/pkg/basis/git"
+ "github.com/bkeane/monad/pkg/basis/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -35,6 +37,16 @@ func (m *MockEcrConfig) RegistryId() string {
return args.String(0)
}
+func (m *MockEcrConfig) Git() *git.Basis {
+ args := m.Called()
+ return args.Get(0).(*git.Basis)
+}
+
+func (m *MockEcrConfig) Service() *service.Basis {
+ args := m.Called()
+ return args.Get(0).(*service.Basis)
+}
+
// MockEcrClient for testing ECR calls
type MockEcrClient struct {
mock.Mock
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 380f907..4d8b7f2 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -3,11 +3,13 @@ package state
import (
"context"
"sort"
+ "time"
"github.com/bkeane/monad/pkg/basis"
"github.com/bkeane/monad/pkg/basis/caller"
"github.com/bkeane/monad/pkg/basis/git"
"github.com/bkeane/monad/pkg/basis/service"
+ "github.com/dustin/go-humanize"
"github.com/aws/aws-sdk-go-v2/service/lambda"
"github.com/charmbracelet/lipgloss/table"
@@ -28,11 +30,12 @@ type Basis interface {
//
type StateMetadata struct {
- Service string
- Owner string
- Repo string
- Branch string
- Sha string
+ Service string
+ Owner string
+ Repo string
+ Branch string
+ Sha string
+ LastModified *time.Time
}
//
@@ -76,6 +79,14 @@ func (s *State) List(ctx context.Context) ([]*StateMetadata, error) {
var services []*StateMetadata
for _, function := range functions.Functions {
if metadata := s.extractFromTags(ctx, *function.FunctionArn); metadata != nil {
+ // Add LastModified timestamp from function data
+ if function.LastModified != nil {
+ // Parse the LastModified timestamp
+ if lastModified, err := time.Parse("2006-01-02T15:04:05.000+0000", *function.LastModified); err == nil {
+ metadata.LastModified = &lastModified
+ }
+ }
+
// Apply filtering based on basis values (* means all)
if s.matchesFilter(metadata) {
services = append(services, metadata)
@@ -113,10 +124,11 @@ func (s *State) Table(ctx context.Context) (string, error) {
})
tbl := table.New()
- tbl.Headers("Service", "Owner", "Repo", "Branch", "Sha")
+ tbl.Headers("Service", "Owner", "Repo", "Branch", "Sha", "Deployed")
for _, service := range services {
- tbl.Row(service.Service, service.Owner, service.Repo, service.Branch, truncate(service.Sha))
+ deployedDisplay := formatTime(service.LastModified)
+ tbl.Row(service.Service, service.Owner, service.Repo, service.Branch, truncate(service.Sha), deployedDisplay)
}
return tbl.Render(), nil
@@ -203,3 +215,11 @@ func truncate(s string) string {
}
return s[:7]
}
+
+// formatTime formats time for display using human-friendly format
+func formatTime(t *time.Time) string {
+ if t == nil {
+ return "-"
+ }
+ return humanize.Time(*t)
+}
diff --git a/pkg/step/apigateway/apigateway.go b/pkg/step/apigateway/apigateway.go
index f6179b3..357319b 100644
--- a/pkg/step/apigateway/apigateway.go
+++ b/pkg/step/apigateway/apigateway.go
@@ -81,6 +81,11 @@ func Derive(apigateway ApiGatewayConfig, lambda LambdaConfig) *Step {
}
func (s *Step) Mount(ctx context.Context) error {
+ // Skip API Gateway setup if no API ID is configured
+ if s.apigateway.ApiId() == "" {
+ return nil
+ }
+
// Call internal unmount silently (don't log deletes)
if _, err := s.unmount(ctx); err != nil {
return err
diff --git a/pkg/step/eventbridge/eventbridge.go b/pkg/step/eventbridge/eventbridge.go
index 4d7acaf..4ec96dd 100644
--- a/pkg/step/eventbridge/eventbridge.go
+++ b/pkg/step/eventbridge/eventbridge.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"strings"
- "unicode"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/eventbridge"
@@ -16,8 +15,7 @@ import (
type EventBridgeConfig interface {
BusName() string
- RuleName() string
- RuleDocument() string
+ Rules() map[string]string
PermissionStatementId() string
Client() *eventbridge.Client
Tags() []eventbridgetypes.Tag
@@ -191,15 +189,14 @@ func (s *Step) PutRule(ctx context.Context, rule EventBridgeRule) error {
// The ScheduleExpression is the odd one out (always a stringy cron-type expression).
if strings.HasPrefix(rule.Document, "cron(") || strings.HasPrefix(rule.Document, "rate(") {
- scheduleExpression := chomp(rule.Document)
- putRuleInput.ScheduleExpression = aws.String(scheduleExpression)
+ putRuleInput.ScheduleExpression = aws.String(rule.Document)
} else {
putRuleInput.EventPattern = aws.String(rule.Document)
}
putTargetsInput := eventbridge.PutTargetsInput{
- EventBusName: aws.String(s.eventbridge.BusName()),
- Rule: aws.String(s.eventbridge.RuleName()),
+ EventBusName: aws.String(rule.BusName),
+ Rule: aws.String(rule.RuleName),
Targets: []eventbridgetypes.Target{
{
Id: aws.String(s.lambda.FunctionName()),
@@ -300,24 +297,33 @@ func (s *Step) DeleteRule(ctx context.Context, rule EventBridgeRule) error {
// GET Operations
func (s *Step) GetDefinedRules(ctx context.Context) (map[string]map[string]EventBridgeRule, error) {
- // This code _can_ handle many defined rules, but monad currently will only support one until more are necessary.
+ // This code can now handle multiple defined rules
busName := s.eventbridge.BusName()
+ rules := s.eventbridge.Rules()
- // Only create rules if a bus is explicitly configured
- if busName == "" {
+ // Only create rules if rules are defined
+ if len(rules) == 0 {
return map[string]map[string]EventBridgeRule{}, nil
}
- document := s.eventbridge.RuleDocument()
+ // If no bus is explicitly configured, use default bus
+ if busName == "" {
+ busName = "default"
+ }
+
ruleMap := map[string]map[string]EventBridgeRule{}
// Initialize the inner map if it doesn't exist
if _, exists := ruleMap[busName]; !exists {
ruleMap[busName] = make(map[string]EventBridgeRule)
}
- ruleMap[busName][s.eventbridge.RuleName()] = EventBridgeRule{
- BusName: busName,
- RuleName: s.eventbridge.RuleName(),
- Document: document,
+
+ // Create EventBridgeRule for each defined rule
+ for ruleName, document := range rules {
+ ruleMap[busName][ruleName] = EventBridgeRule{
+ BusName: busName,
+ RuleName: ruleName,
+ Document: document,
+ }
}
return ruleMap, nil
@@ -406,9 +412,3 @@ func (s *Step) GetAssociatedRules(ctx context.Context) (map[string]map[string]Ev
return associatedRules, nil
}
-// Utility
-func chomp(s string) string {
- s = strings.TrimLeftFunc(s, unicode.IsSpace)
- s = strings.TrimRightFunc(s, unicode.IsSpace)
- return s
-}