feat: add Forgejo webhook trigger support with AWS Secrets Manager
This commit is contained in:
parent
0819ae1a71
commit
bba136cb12
5 changed files with 238 additions and 11 deletions
|
|
@ -8,5 +8,10 @@ COPY ./app/main.go ./
|
||||||
RUN go build -tags lambda.norpc -o main main.go
|
RUN go build -tags lambda.norpc -o main main.go
|
||||||
# Copy artifacts to a clean image
|
# Copy artifacts to a clean image
|
||||||
FROM public.ecr.aws/lambda/provided:al2023
|
FROM public.ecr.aws/lambda/provided:al2023
|
||||||
|
# Install git and zip using dnf (Amazon Linux 2023)
|
||||||
|
RUN dnf update -y && \
|
||||||
|
dnf install -y git zip && \
|
||||||
|
dnf clean all
|
||||||
COPY --from=build /app/main ${LAMBDA_TASK_ROOT}
|
COPY --from=build /app/main ${LAMBDA_TASK_ROOT}
|
||||||
ENTRYPOINT [ "main" ]
|
WORKDIR ${LAMBDA_TASK_ROOT}
|
||||||
|
ENTRYPOINT [ "./main" ]
|
||||||
74
app/main.go
74
app/main.go
|
|
@ -2,6 +2,9 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -24,16 +27,67 @@ type Config struct {
|
||||||
AWSRegion string
|
AWSRegion string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
var commandRunner = exec.Command
|
var commandRunner = exec.Command
|
||||||
|
|
||||||
func handleRequest(ctx context.Context, event json.RawMessage) error {
|
// verifySignature computes an HMAC using the provided secret and compares it to the incoming signature.
|
||||||
|
func verifySignature(secret, body, signatureHeader string) bool {
|
||||||
|
// Assuming the header is in the format "sha256=<signature>"
|
||||||
|
const prefix = "sha256="
|
||||||
|
if len(signatureHeader) < len(prefix) || signatureHeader[:len(prefix)] != prefix {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
receivedSig := signatureHeader[len(prefix):]
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(body))
|
||||||
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequest(ctx context.Context, event json.RawMessage) (Response, error) {
|
||||||
|
// For demonstration, assume the event JSON includes a "body" and "headers" map.
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event, &req); err != nil {
|
||||||
|
log.Printf("Error unmarshalling event: %v", err)
|
||||||
|
return Response{StatusCode: 400, Headers: map[string]string{"Content-Type": "application/json"}, Body: "{\"message\":\"Bad Request\"}"}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := os.Getenv("WEBHOOK_SECRET")
|
||||||
|
if secret == "" {
|
||||||
|
log.Println("WEBHOOK_SECRET is not set")
|
||||||
|
return Response{StatusCode: 500, Headers: map[string]string{"Content-Type": "application/json"}, Body: "{\"message\":\"Server configuration error\"}"}, fmt.Errorf("WEBHOOK_SECRET is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := req.Headers["X-Hub-Signature-256"] // adjust this header name as appropriate.
|
||||||
|
if signature == "" || !verifySignature(secret, req.Body, signature) {
|
||||||
|
log.Println("Signature verification failed")
|
||||||
|
return Response{StatusCode: 401, Headers: map[string]string{"Content-Type": "application/json"}, Body: "{\"message\":\"Unauthorized\"}"}, fmt.Errorf("signature verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
// Call your existing process (for example, runDeploymentProcess)
|
// Call your existing process (for example, runDeploymentProcess)
|
||||||
if err := runDeploymentProcess(ctx); err != nil {
|
if err := runDeploymentProcess(ctx); err != nil {
|
||||||
// Log the error; you may also report it via CloudWatch alarms
|
// Log the error; you may also report it via CloudWatch alarms
|
||||||
log.Printf("Deployment process failed: %v", err)
|
log.Printf("Deployment process failed: %v", err)
|
||||||
return err
|
return Response{
|
||||||
|
StatusCode: 500,
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: fmt.Sprintf("{\"message\": \"Deployment process failed: %v\"}", err),
|
||||||
|
}, err
|
||||||
}
|
}
|
||||||
return nil
|
return Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
Body: "{\"message\": \"Success\"}",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -44,14 +98,14 @@ func runDeploymentProcess(ctx context.Context) error {
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Configuration error: %v", err)
|
log.Printf("Configuration error: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique temp directory for this run
|
// Create a unique temp directory for this run
|
||||||
repoDir, err := os.MkdirTemp("", "repo-*")
|
repoDir, err := os.MkdirTemp("", "repo-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating temporary directory: %v", err)
|
log.Printf("Error creating temporary directory: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(repoDir)
|
defer os.RemoveAll(repoDir)
|
||||||
|
|
@ -59,26 +113,26 @@ func runDeploymentProcess(ctx context.Context) error {
|
||||||
|
|
||||||
// 1. Clone the repository
|
// 1. Clone the repository
|
||||||
if err := cloneRepository(ctx, cfg.RepoURL, cfg.RepoBranch, repoDir); err != nil {
|
if err := cloneRepository(ctx, cfg.RepoURL, cfg.RepoBranch, repoDir); err != nil {
|
||||||
log.Fatalf("Failure in cloning: %v", err)
|
log.Printf("Failure in cloning: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create a ZIP archive of the repository
|
// 2. Create a ZIP archive of the repository
|
||||||
if err := createZipArchive(ctx, repoDir, zipFilePath); err != nil {
|
if err := createZipArchive(ctx, repoDir, zipFilePath); err != nil {
|
||||||
log.Fatalf("Failure in creating ZIP archive: %v", err)
|
log.Printf("Failure in creating ZIP archive: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Upload the ZIP file to S3
|
// 3. Upload the ZIP file to S3
|
||||||
cfg_s3, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.AWSRegion))
|
cfg_s3, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.AWSRegion))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error loading configuration: %v", err)
|
log.Printf("Error loading configuration: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s3Client := s3.NewFromConfig(cfg_s3)
|
s3Client := s3.NewFromConfig(cfg_s3)
|
||||||
uploader := manager.NewUploader(s3Client)
|
uploader := manager.NewUploader(s3Client)
|
||||||
if err := uploadToS3WithUploader(ctx, zipFilePath, cfg.S3Bucket, cfg.S3Key, uploader); err != nil {
|
if err := uploadToS3WithUploader(ctx, zipFilePath, cfg.S3Bucket, cfg.S3Key, uploader); err != nil {
|
||||||
log.Fatalf("Failure in uploading to S3: %v", err)
|
log.Printf("Failure in uploading to S3: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +203,7 @@ func uploadToS3WithUploader(ctx context.Context, zipPath, bucket, key string, up
|
||||||
// Open the ZIP file
|
// Open the ZIP file
|
||||||
f, err := os.Open(zipPath)
|
f, err := os.Open(zipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error opening ZIP file: %v", err)
|
return fmt.Errorf("Error opening ZIP file: %v", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
@ -12,6 +15,57 @@ import (
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestVerifySignature_Valid(t *testing.T) {
|
||||||
|
secret := "mysecret"
|
||||||
|
body := "{\"message\":\"example\"}"
|
||||||
|
|
||||||
|
// Compute the expected signature for the valid scenario.
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(body))
|
||||||
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
// Prepare the signature header in the "sha256=<signature>" format.
|
||||||
|
signatureHeader := "sha256=" + expectedSig
|
||||||
|
|
||||||
|
if !verifySignature(secret, body, signatureHeader) {
|
||||||
|
t.Errorf("Expected true for valid signature, got false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_InvalidSignature(t *testing.T) {
|
||||||
|
secret := "mysecret"
|
||||||
|
body := "{\"message\":\"example\"}"
|
||||||
|
|
||||||
|
// Use an intentionally incorrect signature.
|
||||||
|
signatureHeader := "sha256=invalidsignature"
|
||||||
|
|
||||||
|
if verifySignature(secret, body, signatureHeader) {
|
||||||
|
t.Errorf("Expected false for an invalid signature, but got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_MissingPrefix(t *testing.T) {
|
||||||
|
secret := "mysecret"
|
||||||
|
body := "{\"message\":\"example\"}"
|
||||||
|
|
||||||
|
// Provide a header that does not start with "sha256="
|
||||||
|
signatureHeader := "invalidprefix"
|
||||||
|
|
||||||
|
if verifySignature(secret, body, signatureHeader) {
|
||||||
|
t.Errorf("Expected false when header is missing the required prefix, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySignature_EmptyHeader(t *testing.T) {
|
||||||
|
secret := "mysecret"
|
||||||
|
body := "{\"message\":\"example\"}"
|
||||||
|
signatureHeader := ""
|
||||||
|
|
||||||
|
if verifySignature(secret, body, signatureHeader) {
|
||||||
|
t.Errorf("Expected false when header is empty, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_Success(t *testing.T) {
|
func TestLoadConfig_Success(t *testing.T) {
|
||||||
// Set up environment variables for the test.
|
// Set up environment variables for the test.
|
||||||
os.Setenv("REPO_URL", "https://example.com/repo.git")
|
os.Setenv("REPO_URL", "https://example.com/repo.git")
|
||||||
|
|
|
||||||
26
template-secret-key.yaml
Normal file
26
template-secret-key.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
AWSTemplateFormatVersion: '2010-09-09'
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Parameters:
|
||||||
|
WebhookSecret:
|
||||||
|
Type: String
|
||||||
|
Description: The number as a secret key of a webhook
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
|
||||||
|
SecretForWebhook:
|
||||||
|
Type: AWS::SecretsManager::Secret
|
||||||
|
Properties:
|
||||||
|
Description: The number as a secret key of a webhook
|
||||||
|
SecretString: !Sub
|
||||||
|
- '{"secretNumber": "${WebhookSecret}"}'
|
||||||
|
- WebhookSecret: !Ref WebhookSecret
|
||||||
|
Tags:
|
||||||
|
- Key: Project
|
||||||
|
Value: Git-server
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
ArnSecretForWebhook:
|
||||||
|
Description: ARN of secret key of a webhook
|
||||||
|
Value: !Ref SecretForWebhook
|
||||||
|
Export:
|
||||||
|
Name: SecretForWebhook-ARN
|
||||||
88
template.yaml
Normal file
88
template.yaml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
AWSTemplateFormatVersion: '2010-09-09'
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Parameters:
|
||||||
|
StageName:
|
||||||
|
Type: String
|
||||||
|
Default: Prod
|
||||||
|
Description: Name of the API stage.
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
|
||||||
|
MyLambdaRole:
|
||||||
|
Type: AWS::IAM::Role
|
||||||
|
Properties:
|
||||||
|
AssumeRolePolicyDocument:
|
||||||
|
Version: '2012-10-17'
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Service:
|
||||||
|
- lambda.amazonaws.com
|
||||||
|
Action: sts:AssumeRole
|
||||||
|
Policies:
|
||||||
|
- PolicyName: LambdaS3PutObjectPolicy
|
||||||
|
PolicyDocument:
|
||||||
|
Version: '2012-10-17'
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- s3:PutObject
|
||||||
|
Resource: arn:aws:s3:::naputo-blog-source/*
|
||||||
|
ManagedPolicyArns:
|
||||||
|
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
|
||||||
|
|
||||||
|
MyLambdaFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
PackageType: Image
|
||||||
|
ImageUri: 692859919890.dkr.ecr.ap-northeast-1.amazonaws.com/blog-deployment:latest
|
||||||
|
Timeout: 30
|
||||||
|
MemorySize: 256
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
REPO_URL: "https://git.n-daisuke897.com/nakada0907/n-daisuke897-blog.git"
|
||||||
|
REPO_BRANCH: "main"
|
||||||
|
S3_BUCKET: "naputo-blog-source"
|
||||||
|
S3_KEY: "source.zip"
|
||||||
|
WEBHOOK_SECRET:
|
||||||
|
Fn::Sub:
|
||||||
|
- "{{resolve:secretsmanager:${SecretArn}:SecretString:secretNumber:AWSCURRENT}}"
|
||||||
|
- SecretArn:
|
||||||
|
Fn::ImportValue: SecretForWebhook-ARN
|
||||||
|
Role: !GetAtt MyLambdaRole.Arn
|
||||||
|
Events:
|
||||||
|
ForgejoWebhook:
|
||||||
|
Type: Api
|
||||||
|
Properties:
|
||||||
|
RestApiId: !Ref MyApi
|
||||||
|
Path: /forgejo-webhook
|
||||||
|
Method: POST
|
||||||
|
|
||||||
|
MyApi:
|
||||||
|
Type: AWS::Serverless::Api
|
||||||
|
Properties:
|
||||||
|
StageName: !Ref StageName
|
||||||
|
EndpointConfiguration: REGIONAL
|
||||||
|
DefinitionBody:
|
||||||
|
openapi: "3.0.1"
|
||||||
|
info:
|
||||||
|
title: "Forgejo Webhook API"
|
||||||
|
version: "1.0"
|
||||||
|
paths:
|
||||||
|
/forgejo-webhook:
|
||||||
|
post:
|
||||||
|
summary: "Trigger Lambda via Forgejo Webhook"
|
||||||
|
x-amazon-apigateway-integration:
|
||||||
|
uri:
|
||||||
|
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MyLambdaFunction.Arn}/invocations
|
||||||
|
httpMethod: POST
|
||||||
|
type: aws_proxy
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: "Successful response"
|
||||||
|
'400':
|
||||||
|
description: "Bad Request - Incorrect request payload format"
|
||||||
|
'401':
|
||||||
|
description: "Unauthorized - Signature verification failed"
|
||||||
|
'500':
|
||||||
|
description: "Server error - Deployment process failed"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue