mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: Implement AI Gateway page with feature tiles and descriptions
feat: Update AppSidebar to include new Environments and Credentials sections feat: Enhance node form with branch selection for triggers and deployment feat: Create CredentialForm and CredentialRow components for managing credentials feat: Add API endpoints for credential management fix: Update node catalog defaults for deploy and promote nodes
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
// Package awsdeploy ports the AWS pieces of agent-deploy/build_service:
|
||||
// cross-account STS AssumeRole, idempotent ECR + AgentCore-runtime-role
|
||||
// bootstrap, and AgentCore control-plane create-or-update + endpoint
|
||||
// readiness polling. It is the AWS-target adapter for the Flow Deploy
|
||||
// node (see pkg/executors/deploy.go).
|
||||
//
|
||||
// AWS does not (yet) ship a Go SDK client for bedrock-agentcore-control,
|
||||
// so AgentCore calls are raw HTTPS signed with SigV4 — the same approach
|
||||
// the JS reference uses.
|
||||
package awsdeploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
||||
ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
// Logger is the minimal sink the executor's per-node logger satisfies.
|
||||
// Pass `engine.NodeLoggerFromContext(ctx)` from the caller.
|
||||
type Logger interface {
|
||||
Log(line string)
|
||||
}
|
||||
|
||||
type nopLogger struct{}
|
||||
|
||||
func (nopLogger) Log(string) {}
|
||||
|
||||
// loggerOr returns lg if non-nil, else a no-op.
|
||||
func loggerOr(lg Logger) Logger {
|
||||
if lg == nil {
|
||||
return nopLogger{}
|
||||
}
|
||||
return lg
|
||||
}
|
||||
|
||||
// Config is everything the deploy executor passes once per call.
|
||||
type Config struct {
|
||||
Region string
|
||||
AccountID string
|
||||
CrossAccountRoleArn string // role in the customer account; this host assumes it
|
||||
}
|
||||
|
||||
// AssumeCustomer returns AWS creds for the customer account by calling
|
||||
// STS AssumeRole on the configured cross-account role. The Flow host's
|
||||
// own creds (env / IRSA / instance profile) authenticate the AssumeRole.
|
||||
func AssumeCustomer(ctx context.Context, c Config) (aws.CredentialsProvider, error) {
|
||||
if c.Region == "" || c.CrossAccountRoleArn == "" {
|
||||
return nil, errors.New("awsdeploy: region and crossAccountRoleArn are required")
|
||||
}
|
||||
hostCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(c.Region))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load host AWS config: %w", err)
|
||||
}
|
||||
stsCli := sts.NewFromConfig(hostCfg)
|
||||
prov := stscreds.NewAssumeRoleProvider(stsCli, c.CrossAccountRoleArn, func(o *stscreds.AssumeRoleOptions) {
|
||||
o.RoleSessionName = "flow-deploy"
|
||||
o.Duration = time.Hour
|
||||
})
|
||||
// Force one resolution up-front so we surface auth errors here, not on
|
||||
// the first AWS call inside an executor.
|
||||
if _, err := prov.Retrieve(ctx); err != nil {
|
||||
return nil, fmt.Errorf("assume role %s: %w", c.CrossAccountRoleArn, err)
|
||||
}
|
||||
return aws.NewCredentialsCache(prov), nil
|
||||
}
|
||||
|
||||
// CustomerConfig is an aws.Config with the cross-account creds attached.
|
||||
func CustomerConfig(ctx context.Context, c Config, creds aws.CredentialsProvider) (aws.Config, error) {
|
||||
return config.LoadDefaultConfig(ctx,
|
||||
config.WithRegion(c.Region),
|
||||
config.WithCredentialsProvider(creds),
|
||||
)
|
||||
}
|
||||
|
||||
// EnsureECRRepository creates the repo if missing. Idempotent — already-
|
||||
// exists is treated as success. Returns the repo URI (registry/name).
|
||||
func EnsureECRRepository(ctx context.Context, awsCfg aws.Config, accountID, region, repoName string, lg Logger) (string, error) {
|
||||
lg = loggerOr(lg)
|
||||
cli := ecr.NewFromConfig(awsCfg)
|
||||
scanOnPush := true
|
||||
_, err := cli.CreateRepository(ctx, &ecr.CreateRepositoryInput{
|
||||
RepositoryName: aws.String(repoName),
|
||||
ImageScanningConfiguration: &ecrtypes.ImageScanningConfiguration{
|
||||
ScanOnPush: scanOnPush,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) && ae.ErrorCode() == "RepositoryAlreadyExistsException" {
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] ECR repo %s already exists", repoName))
|
||||
} else {
|
||||
return "", fmt.Errorf("create ECR repo %s: %w", repoName, err)
|
||||
}
|
||||
} else {
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] ECR repo %s created", repoName))
|
||||
}
|
||||
return fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s", accountID, region, repoName), nil
|
||||
}
|
||||
|
||||
// EnsureAgentCoreRuntimeRole creates the shared `agentcore-runtime-role`
|
||||
// (per the JS reference) if missing, and attaches a runtime policy. The
|
||||
// role's trust policy allows bedrock-agentcore.amazonaws.com to assume it,
|
||||
// scoped to the customer account. Idempotent.
|
||||
func EnsureAgentCoreRuntimeRole(ctx context.Context, awsCfg aws.Config, accountID, region string, lg Logger) (string, error) {
|
||||
lg = loggerOr(lg)
|
||||
const roleName = "agentcore-runtime-role"
|
||||
const policyName = "AgentCoreRuntimePolicy"
|
||||
|
||||
cli := iam.NewFromConfig(awsCfg)
|
||||
if out, err := cli.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}); err == nil {
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] IAM role %s exists", roleName))
|
||||
return aws.ToString(out.Role.Arn), nil
|
||||
} else {
|
||||
var ae smithy.APIError
|
||||
if !errors.As(err, &ae) || ae.ErrorCode() != "NoSuchEntity" {
|
||||
return "", fmt.Errorf("get role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
trust := mustJSON(map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{{
|
||||
"Effect": "Allow",
|
||||
"Principal": map[string]any{"Service": "bedrock-agentcore.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole",
|
||||
"Condition": map[string]any{"StringEquals": map[string]string{"aws:SourceAccount": accountID}},
|
||||
}},
|
||||
})
|
||||
createOut, err := cli.CreateRole(ctx, &iam.CreateRoleInput{
|
||||
RoleName: aws.String(roleName),
|
||||
AssumeRolePolicyDocument: aws.String(trust),
|
||||
Description: aws.String("Shared AgentCore runtime role created by Flow"),
|
||||
})
|
||||
if err != nil {
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) && ae.ErrorCode() == "EntityAlreadyExists" {
|
||||
// Race: another deploy created it between Get and Create.
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] IAM role %s won the race", roleName))
|
||||
out, gerr := cli.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)})
|
||||
if gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
return aws.ToString(out.Role.Arn), nil
|
||||
}
|
||||
return "", fmt.Errorf("create role: %w", err)
|
||||
}
|
||||
roleArn := aws.ToString(createOut.Role.Arn)
|
||||
|
||||
policy := mustJSON(map[string]any{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": []map[string]any{
|
||||
{"Effect": "Allow", "Action": []string{"ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:GetAuthorizationToken"}, "Resource": "*"},
|
||||
{"Effect": "Allow", "Action": []string{"logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams", "logs:DescribeLogGroups"}, "Resource": "*"},
|
||||
{"Effect": "Allow", "Action": []string{"xray:PutTraceSegments", "xray:PutTelemetryRecords", "xray:GetSamplingRules", "xray:GetSamplingTargets"}, "Resource": "*"},
|
||||
{"Effect": "Allow", "Action": "cloudwatch:PutMetricData", "Resource": "*", "Condition": map[string]any{"StringEquals": map[string]string{"cloudwatch:namespace": "bedrock-agentcore"}}},
|
||||
{"Effect": "Allow", "Action": []string{"bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"}, "Resource": fmt.Sprintf("arn:aws:bedrock:%s::foundation-model/*", region)},
|
||||
{"Effect": "Allow", "Action": []string{"bedrock-agentcore:GetWorkloadAccessToken", "bedrock-agentcore:GetWorkloadAccessTokenForJWT", "bedrock-agentcore:GetWorkloadAccessTokenForUserId"}, "Resource": "*"},
|
||||
},
|
||||
})
|
||||
if _, err := cli.PutRolePolicy(ctx, &iam.PutRolePolicyInput{
|
||||
RoleName: aws.String(roleName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policy),
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("put role policy: %w", err)
|
||||
}
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] IAM role %s created; sleeping 10s for propagation", roleName))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
return roleArn, nil
|
||||
}
|
||||
|
||||
// AgentRuntimeResult holds the post-deploy identifiers returned to the
|
||||
// Deploy executor's __deploy summary. EndpointArn + InvokeURL are what
|
||||
// users actually want at the end of the run.
|
||||
type AgentRuntimeResult struct {
|
||||
AgentRuntimeID string
|
||||
AgentRuntimeArn string
|
||||
EndpointArn string
|
||||
InvokeURL string
|
||||
}
|
||||
|
||||
// CreateOrUpdateAgentRuntime puts a new AgentCore runtime (or updates the
|
||||
// existing one with the same name) and waits for the DEFAULT endpoint to
|
||||
// reach READY. Mirrors deploy_to_agentcore.js exactly. timeout caps the
|
||||
// readiness wait; total time also bounded by ctx.
|
||||
func CreateOrUpdateAgentRuntime(
|
||||
ctx context.Context,
|
||||
c Config,
|
||||
creds aws.CredentialsProvider,
|
||||
runtimeName, image, runtimeRoleArn string,
|
||||
envVars map[string]string,
|
||||
timeout time.Duration,
|
||||
lg Logger,
|
||||
) (AgentRuntimeResult, error) {
|
||||
lg = loggerOr(lg)
|
||||
if c.Region == "" {
|
||||
return AgentRuntimeResult{}, errors.New("region required")
|
||||
}
|
||||
if image == "" {
|
||||
return AgentRuntimeResult{}, errors.New("image required")
|
||||
}
|
||||
if runtimeRoleArn == "" {
|
||||
return AgentRuntimeResult{}, errors.New("runtimeRoleArn required")
|
||||
}
|
||||
|
||||
// AgentCore enforces [a-zA-Z0-9_]{1,48}
|
||||
agentName := sanitizeAgentName(runtimeName)
|
||||
createBody := map[string]any{
|
||||
"agentRuntimeName": agentName,
|
||||
"agentRuntimeArtifact": map[string]any{
|
||||
"containerConfiguration": map[string]any{"containerUri": image},
|
||||
},
|
||||
"roleArn": runtimeRoleArn,
|
||||
"networkConfiguration": map[string]any{"networkMode": "PUBLIC"},
|
||||
}
|
||||
if len(envVars) > 0 {
|
||||
createBody["environmentVariables"] = envVars
|
||||
}
|
||||
|
||||
var (
|
||||
agentID string
|
||||
agentArn string
|
||||
)
|
||||
|
||||
// Try create.
|
||||
respCreate, status, err := agentcoreRequest(ctx, "PUT", "/runtimes/", createBody, c.Region, creds)
|
||||
switch {
|
||||
case err == nil:
|
||||
agentID, _ = respCreate["agentRuntimeId"].(string)
|
||||
agentArn, _ = respCreate["agentRuntimeArn"].(string)
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] AgentCore created runtime %s", agentID))
|
||||
case status == http.StatusConflict || isConflict(err):
|
||||
// Update path: list, find by name, update.
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] AgentCore runtime %q exists; updating", agentName))
|
||||
listResp, _, lerr := agentcoreRequest(ctx, "POST", "/runtimes/", map[string]any{"maxResults": 100}, c.Region, creds)
|
||||
if lerr != nil {
|
||||
return AgentRuntimeResult{}, fmt.Errorf("list runtimes: %w", lerr)
|
||||
}
|
||||
runtimes, _ := listResp["agentRuntimes"].([]any)
|
||||
for _, r := range runtimes {
|
||||
rm, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rm["agentRuntimeName"] == agentName {
|
||||
agentID, _ = rm["agentRuntimeId"].(string)
|
||||
agentArn, _ = rm["agentRuntimeArn"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if agentID == "" {
|
||||
return AgentRuntimeResult{}, fmt.Errorf("conflict but runtime %q not found in list", agentName)
|
||||
}
|
||||
updateBody := map[string]any{
|
||||
"agentRuntimeArtifact": map[string]any{
|
||||
"containerConfiguration": map[string]any{"containerUri": image},
|
||||
},
|
||||
"roleArn": runtimeRoleArn,
|
||||
"networkConfiguration": map[string]any{"networkMode": "PUBLIC"},
|
||||
}
|
||||
if len(envVars) > 0 {
|
||||
updateBody["environmentVariables"] = envVars
|
||||
}
|
||||
if _, _, uerr := agentcoreRequest(ctx, "PUT", "/runtimes/"+agentID+"/", updateBody, c.Region, creds); uerr != nil {
|
||||
return AgentRuntimeResult{}, fmt.Errorf("update runtime: %w", uerr)
|
||||
}
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] AgentCore updated runtime %s", agentID))
|
||||
default:
|
||||
return AgentRuntimeResult{}, fmt.Errorf("create runtime: %w", err)
|
||||
}
|
||||
|
||||
// Wait for DEFAULT endpoint READY.
|
||||
endpointArn, werr := waitEndpointReady(ctx, agentID, c.Region, creds, timeout, lg)
|
||||
if werr != nil {
|
||||
return AgentRuntimeResult{
|
||||
AgentRuntimeID: agentID,
|
||||
AgentRuntimeArn: agentArn,
|
||||
}, werr
|
||||
}
|
||||
invokeURL := fmt.Sprintf("https://bedrock-agentcore.%s.amazonaws.com/runtimes/%s/invocations", c.Region, agentID)
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] AgentCore endpoint READY: %s", invokeURL))
|
||||
return AgentRuntimeResult{
|
||||
AgentRuntimeID: agentID,
|
||||
AgentRuntimeArn: agentArn,
|
||||
EndpointArn: endpointArn,
|
||||
InvokeURL: invokeURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func waitEndpointReady(ctx context.Context, agentID, region string, creds aws.CredentialsProvider, timeout time.Duration, lg Logger) (string, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
path := fmt.Sprintf("/runtimes/%s/runtime-endpoints/DEFAULT/", agentID)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("endpoint not READY within %s", timeout)
|
||||
}
|
||||
resp, status, err := agentcoreRequest(ctx, "GET", path, nil, region, creds)
|
||||
if err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
lg.Log("[awsdeploy] endpoint not provisioned yet, waiting...")
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
s, _ := resp["status"].(string)
|
||||
lg.Log(fmt.Sprintf("[awsdeploy] endpoint status: %s", s))
|
||||
if s == "READY" {
|
||||
arn, _ := resp["agentRuntimeEndpointArn"].(string)
|
||||
return arn, nil
|
||||
}
|
||||
if strings.Contains(s, "FAILED") {
|
||||
reason, _ := resp["failureReason"].(string)
|
||||
return "", fmt.Errorf("endpoint failed: %s (%s)", s, reason)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// agentcoreRequest signs and sends a request to bedrock-agentcore-control.
|
||||
// Returns the parsed JSON response, HTTP status, and a non-nil error if
|
||||
// status >= 400 or transport failed.
|
||||
func agentcoreRequest(
|
||||
ctx context.Context,
|
||||
method, path string,
|
||||
body map[string]any,
|
||||
region string,
|
||||
creds aws.CredentialsProvider,
|
||||
) (map[string]any, int, error) {
|
||||
host := fmt.Sprintf("bedrock-agentcore-control.%s.amazonaws.com", region)
|
||||
endpoint := url.URL{Scheme: "https", Host: host, Path: path}
|
||||
|
||||
var bodyBytes []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
bodyBytes, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("content-type", "application/json")
|
||||
req.Host = host
|
||||
|
||||
c, err := creds.Retrieve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("retrieve creds: %w", err)
|
||||
}
|
||||
hash := sha256Hex(bodyBytes)
|
||||
signer := v4.NewSigner()
|
||||
if err := signer.SignHTTP(ctx, c, req, hash, "bedrock-agentcore", region, time.Now()); err != nil {
|
||||
return nil, 0, fmt.Errorf("sigv4 sign: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var parsed map[string]any
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &parsed)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
msg := ""
|
||||
if parsed != nil {
|
||||
if m, ok := parsed["message"].(string); ok {
|
||||
msg = m
|
||||
} else if m, ok := parsed["Message"].(string); ok {
|
||||
msg = m
|
||||
}
|
||||
}
|
||||
if msg == "" {
|
||||
msg = string(raw)
|
||||
}
|
||||
return parsed, resp.StatusCode, fmt.Errorf("agentcore HTTP %d: %s", resp.StatusCode, msg)
|
||||
}
|
||||
return parsed, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func isConflict(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "conflict")
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func sanitizeAgentName(in string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range in {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if len(out) > 48 {
|
||||
out = out[:48]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user