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:

- -

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:

-
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 -}