Adding Bookmarks to My Blog Part 1: Synchronisation

Keeping on top of the ever-changing landscape of the software industry is a challenge in itself. It seems that every day a new tool or framework promises to make the job easier, and much of what I learn arrives via newsletters, websites, Slack channels and co-workers. Keeping on top of it all is an overwhelming and daunting task which usually results in numerous browser tabs being open - and a lot of memory being used.
One way of taming the problem is a bookmarking service: save links, organise them into collections, and find them again later without leaving a trail of open tabs behind.
This is the first in a series of posts about how I sync bookmarks from Raindrop.io to my blog using DynamoDB, Lambda and a Python script. In this post I cover the sync Lambda itself: pulling bookmarks from the Raindrop.io API, storing them in DynamoDB and saving cover images to S3. Later posts cover generating the Markdown pages for Hugo and the troubleshooting that turned up along the way.
Contents
- Bookmark services - bookmark services I’ve used
- Sharing bookmarks - thinking about how to share bookmarks
- Hugo - integrating with the Hugo static site generator
- AWS Resources - resources used to support bookmark synchronisation
- Raindrop Sync Lambda - Lambda used to synchronise bookmarks
- Sync Lambda deployment with SAM - deploying the Lambda to AWS with SAM
- Deployment Workflow - GitHub Actions deployment workflow
- IAM Policy - IAM policy used to access the Lambda and schedule
Bookmark services
When I first started using bookmark services, I was using Pocket (formerly known as Read It Later) before it got shut down. One of the best things about Pocket was the ability to synchronise bookmarks across devices - I could pull up an article on my phone while on the train just as easily as on my laptop.
So when Pocket shut down, I looked for alternatives and found Raindrop.io, which offered similar functionality. There was a mobile app - crucial for my use case - as well as an API. Raindrop.io could also import my collection from Pocket, which made the switch painless.
Sharing bookmarks
When putting together my blog, I wanted a way to show not only what I’ve been writing about, but also what I’ve been reading. While Pocket had a ‘Share’ feature, doing so required the other person to also have a Pocket account.
Raindrop.io has a similar feature called ‘Public page’ which shares a collection on a URL similar to username.raindrop.page/collection-name, but the downside is that it takes the reader away from my site.
There’s also the option to embed the collection in a page, but this uses an iframe which is hard to style, especially on mobile devices. The iframe also lacks the ability to filter the articles by date, and while tags are displayed, they aren’t easy to navigate.
What I really wanted was a way to list the bookmarks directly on my blog. My first thought was to have the blog access the Raindrop.io API directly to retrieve articles and serve them up on the site. However, there are a few problems with this approach:
- Every visitor to the site means querying the API to retrieve the articles
- Given the API is rate limited, I would need to cache the results somehow
- Storing data server-side would require a database on the backend for the site
- An API key would need to be sent client-side, which for obvious reasons, is not a great idea
At first, I considered using API Gateway with a database - but this would be no different to accessing the Raindrop.io API directly.
Hugo
As mentioned in my welcome post, I use Hugo to generate my blog. Hugo is a static site generator, which means it takes Markdown pages and generates the HTML for my blog before publishing to an S3 bucket.
And that’s where the pieces fell into place: if I could generate the Markdown for the bookmark pages, hugo build would pick the files up from the content/bookmarks directory and do the rest. There would be no runtime API dependency, the content could be cached by the CDN, and the rate limit on the Raindrop.io API stops being a concern altogether.
Given the blog already uses CloudFront, any static content could be cached by the CDN - covers and all.
The next question was how to get the data into Markdown form. The Raindrop.io API returns data in paginated JSON format, and my blog deploys the main branch on every push - sometimes several times a day - so I didn’t want to query the API on every build. Instead, I pull all the bookmarks into a DynamoDB table on a schedule, and the build reads from there.
That leaves the bookmark covers. These are displayed in the Raindrop.io web app, and each one lives at a different URL:
So the covers get pulled from the API response and stored in S3, and a script pulls the JSON data from the DynamoDB table and generates the Markdown files. When hugo build runs, it picks up all the files in the content/bookmarks directory and generates the corresponding HTML pages.
AWS Resources
In terms of AWS resources, I first needed a Lambda function to pull the bookmarks from the API and store them in DynamoDB, along with a CloudWatch event to trigger it on a schedule.
For scripting both the Lambda and the bookmark file generation during the build, I decided to use Python.
To illustrate the overall architecture, here’s a diagram:
Raindrop Sync Lambda
My initial implementation for the Lambda function just took the JSON data from the Raindrop.io API and stored it in a DynamoDB table:
- Read the bearer token from SSM Parameter Store
- Query the API and keep iterating until there are no more pages
- Flatten the JSON data and upsert it into the DynamoDB table
- Remove any stale table records that are not in the API response
Once that was working, the next step was to download the bookmark covers and push them to an S3 bucket. To achieve this, a thread pool of workers is spawned to download each of the covers and save them to the S3 bucket, using the ID of the article as the file name:
The script consists of the following 12 methods:
| Name | Description |
|---|---|
get_config | Retrieves the configuration from the command-line, otherwise falls back to the defaults |
init_clients | Initialises boto3 clients for SSM, S3 and DynamoDB |
get_table | Gets the DynamoDB table reference |
get_bearer_token | Retrieves the Raindrop.io API bearer token from SSM |
fetch_page | Fetches a single page of bookmarks with retry/backoff |
fetch_all_items | Iterates through the paginated list of bookmarks until all have been retrieved |
pick_cover_url | Decides whether to use the bookmark cover URL or the default placeholder image |
ext_for | Determines the content type of the bookmark cover based on the Content-Type header and file extension |
sniff_image | Validates the downloaded bytes against known image signatures, so HTML error pages don’t get saved as covers |
sync_cover | Downloads the bookmark cover from the URL in the bookmark data and stores it in the S3 bucket |
flatten_item | Takes the JSON returned from the API response and flattens it for storage in the DynamoDB table |
lambda_handler | Gets script arguments, fetches items, batch-writes the records and removes stale records |
The full script for this Lambda is as follows:
"""
Sync a Raindrop.io collection to DynamoDB, including cover images.
Phases:
1. Paginate https://api.raindrop.io/rest/v1/raindrops/{id}?page={n}
2. Download each bookmark's cover image (thread pool) and upload to S3
under covers/{collection_id}/{_id}.{ext} - skipped if already present
3. Batch-write records to DynamoDB (keyed on collectionId + _id), storing
the cover's S3 key/url in the record
4. Delete stale records (and their cover objects) whose `_id` is absent
from the latest API response
Environment variables (Lambda):
RAINDROP_COLLECTION_ID : numeric collection id
SSM_TOKEN_PARAMETER : SSM parameter holding the bearer token
DDB_TABLE_NAME : DynamoDB table name
COVER_BUCKET : S3 bucket for cover images (omit to skip)
COVER_BASE_URL : optional public base URL, e.g.
https://cdn.example.com - record.coverUrl is
derived from it when set
Local testing:
python raindrop_sync.py [--dry-run] [--no-covers] [--profile X] [--region Y]
"""
import argparse
import concurrent.futures
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
if len(logging.getLogger().handlers) > 0:
logging.getLogger().setLevel(logging.INFO)
else:
logging.basicConfig(level=logging.INFO)
RAINDROP_API_BASE = "https://api.raindrop.io/rest/v1/raindrops"
PAGE_SIZE = 50
MAX_PAGES = 500
MAX_RETRIES = 3
HTTP_TIMEOUT = 30
# Cover images: cap size, map content-types to extensions
MAX_COVER_BYTES = 10 * 1024 * 1024
EXT_BY_TYPE = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/avif": ".avif",
}
USER_AGENT = "raindrop-sync-lambda/1.0"
COVER_WORKERS = 8
# Defaults used only when the corresponding env var is unset (local runs).
DEFAULTS = {
"RAINDROP_COLLECTION_ID": "12345678",
"SSM_TOKEN_PARAMETER": "/maceage-blog-raindrop-sync/bearer-token",
"DDB_TABLE_NAME": "maceage-blog-raindrops",
"COVER_BUCKET": "blog.maceage.com-raindrop"
}
_ssm = None
_table = None
_s3 = None
def get_config(name: str) -> str:
"""Read a setting from the environment, falling back to a default."""
return os.environ.get(name, DEFAULTS.get(name, ""))
def init_clients(profile_name=None, region=None):
"""Initialise boto3 clients, optionally with a local profile/region."""
global _ssm, _table, _s3
session_kwargs = {}
if profile_name:
session_kwargs["profile_name"] = profile_name
if region:
session_kwargs["region_name"] = region
session = boto3.Session(**session_kwargs)
_ssm = session.client("ssm")
_s3 = session.client("s3")
_table = session.resource("dynamodb").Table(get_config("DDB_TABLE_NAME"))
def get_table():
if _table is None:
init_clients()
return _table
def get_bearer_token() -> str:
"""Retrieve the bearer token from SSM Parameter Store."""
if _ssm is None:
init_clients()
response = _ssm.get_parameter(
Name=get_config("SSM_TOKEN_PARAMETER"),
WithDecryption=True,
)
return response["Parameter"]["Value"]
def fetch_page(collection_id: int, page: int, token: str) -> dict:
"""Fetch a single page of raindrops with retry/backoff."""
url = f"{RAINDROP_API_BASE}/{collection_id}?page={page}&perpage={PAGE_SIZE}"
headers = {"Authorization": f"Bearer {token}"}
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
last_error = exc
wait = 2 ** attempt
logger.warning("Page %d attempt %d failed (%s), retrying in %ds",
page, attempt, exc, wait)
time.sleep(wait)
raise RuntimeError(f"Failed to fetch page {page} after {MAX_RETRIES} attempts") from last_error
def fetch_all_items(collection_id: int, token: str) -> list:
"""Phase 1: paginate the API into a flat list of items."""
items = []
for page in range(0, MAX_PAGES):
payload = fetch_page(collection_id, page, token)
page_items = payload.get("items", [])
if not page_items:
break
items.extend(page_items)
total = payload.get("count")
fetched = (page * PAGE_SIZE) + len(page_items)
logger.info("Page %d: %d items (total reported: %s, fetched so far: %d)",
page, len(page_items), total, fetched)
if total is not None and fetched >= total:
break
else:
raise RuntimeError(f"Exceeded MAX_PAGES ({MAX_PAGES}) - aborting sync")
return items
def pick_cover_url(item: dict):
"""Explicit cover first, then the first image-sized media entry."""
cover = item.get("cover")
if isinstance(cover, str) and cover.startswith("https://"):
return cover
for media in item.get("media") or []:
if media.get("type") == "image":
link = media.get("link")
if isinstance(link, str) and link.startswith("https://"):
return link
return None
def ext_for(content_type: str, url: str) -> str:
"""Extension from content-type, falling back to the URL path suffix."""
ct = (content_type or "").split(";")[0].strip().lower()
if ct in EXT_BY_TYPE:
return EXT_BY_TYPE[ct]
suffix = os.path.splitext(urlparse(url).path)[1].lower()
if suffix in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"):
return ".jpg" if suffix == ".jpeg" else suffix
return ".jpg" # raindrop covers are overwhelmingly jpegs
def sniff_image(data: bytes) -> bool:
"""True if the byte stream starts with a known image signature."""
if len(data) < 12:
return False
if data[:3] == b"\xff\xd8\xff": # JPEG
return True
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
return True
if data[:6] in (b"GIF87a", b"GIF89a"): # GIF
return True
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
return True
if data[4:8] == b"ftyp" and data[8:12].startswith(b"avi"): # AVIF
return True
return False
def sync_cover(bucket: str, collection_id: int, item: dict, dry_run: bool):
"""
Phase 2 worker: download this bookmark's cover and upload it to S3,
keyed by the bookmark's _id. Returns the S3 key, or None if there is
no cover, it already exists, or the download failed (non-fatal -
a missing thumbnail shouldn't sink the whole sync).
"""
item_id = item.get("_id")
url = pick_cover_url(item)
if not item_id or not url:
return None
key = None
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
content_type = resp.headers.get("Content-Type", "")
data = resp.read(MAX_COVER_BYTES + 1)
if len(data) > MAX_COVER_BYTES:
logger.warning("Cover for %d exceeds %d bytes; skipping",
item_id, MAX_COVER_BYTES)
return None
if not data:
logger.warning("Cover for %d is empty; skipping", item_id)
return None
if not sniff_image(data):
logger.warning(
"Cover for %d is not a recognisable image "
"(possibly HTML error page, %d bytes); skipping", item_id, len(data))
return None
if dry_run:
return f"(dry-run) bookmark-covers/{collection_id}/{item_id}{ext_for(content_type, url)}"
key = f"bookmark-covers/{collection_id}/{item_id}{ext_for(content_type, url)}"
# Skip re-upload if we already have this cover (one-time cost per
# bookmark; a changed cover on Raindrop will rarely need refreshing,
# and the existence check saves ~1,400 downloads per nightly sync)
try:
_s3.head_object(Bucket=bucket, Key=key)
return key
except ClientError as exc:
if exc.response["Error"]["Code"] not in ("404", "403", "NoSuchKey"):
raise
_s3.put_object(
Bucket=bucket,
Key=key,
Body=data,
ContentType=content_type.split(";")[0].strip() or "application/octet-stream",
CacheControl="public, max-age=604800",
)
return key
except Exception as exc:
last_error = exc
wait = 2 ** attempt
logger.warning("Cover fetch for %d attempt %d failed (%s), retrying in %ds",
item_id, attempt, exc, wait)
time.sleep(wait)
logger.error("Giving up on cover for %d after %d attempts: %s",
item_id, MAX_RETRIES, last_error)
return None
def flatten_item(item: dict, collection_id: int, cover_key, cover_url) -> dict:
"""
Phase 3: Build the DynamoDB record from a raindrop item.
The `_id` field becomes the table's sort key `id`, and `collectionId`
is forced to the collection we're syncing so composite-key queries work.
"""
record = {
"collectionId": collection_id,
"id": item["_id"],
"title": item["title"],
"link": item["link"],
"domain": item["domain"],
"tags": item["tags"],
"created": item["created"],
"raindrop": item,
"syncedAt": int(time.time()),
}
if cover_key:
record["coverKey"] = cover_key
if cover_url:
record["coverUrl"] = cover_url
return record
def lambda_handler(event, context):
collection_id = int(get_config("RAINDROP_COLLECTION_ID"))
token = get_bearer_token()
table = get_table()
cover_bucket = get_config("COVER_BUCKET")
cover_base = get_config("COVER_BASE_URL").rstrip("/")
dry_run = bool(event.get("dry_run")) if isinstance(event, dict) else False
# ------------------------------------------------------------------
# 1 + 2. Fetch items, then fan cover downloads out to a thread pool
# ------------------------------------------------------------------
items = fetch_all_items(collection_id, token)
cover_keys = {}
if cover_bucket:
if dry_run:
logger.info("Dry run: cover fetch simulated")
with concurrent.futures.ThreadPoolExecutor(max_workers=COVER_WORKERS) as pool:
futures = {
pool.submit(sync_cover, cover_bucket, collection_id, item, dry_run): item
for item in items
}
done, failed = 0, 0
for future in concurrent.futures.as_completed(futures):
item = futures[future]
done += 1
try:
key = future.result()
except Exception as exc: # workers already retried internally
key = None
failed += 1
logger.error("Cover worker failed for %s: %s", item.get("_id"), exc)
if key:
cover_keys[item["_id"]] = key
if done % 250 == 0:
logger.info("Cover progress: %d/%d done (%d failed)", done, len(items), failed)
logger.info("Covers resolved: %d of %d items", len(cover_keys), len(items))
else:
logger.warning("COVER_BUCKET not set - skipping cover sync")
# ------------------------------------------------------------------
# 3. Batch-write records (cover key/url embedded per record)
# ------------------------------------------------------------------
seen_ids = set()
with table.batch_writer() as batch:
for item in items:
key = cover_keys.get(item["_id"])
url = f"{cover_base}/{key}" if key and cover_base else None
record = flatten_item(item, collection_id, key, url)
seen_ids.add(record["id"])
if not dry_run:
batch.put_item(Item=record)
# ------------------------------------------------------------------
# 4. Remove stale records AND their cover objects, matched on `_id`
# ------------------------------------------------------------------
deleted = 0
covers_deleted = 0
if not dry_run:
with table.batch_writer() as batch:
response = table.query(
KeyConditionExpression=boto3.dynamodb.conditions.Key("collectionId").eq(collection_id),
ProjectionExpression="id, coverKey",
)
while True:
for row in response.get("Items", []):
if row["id"] not in seen_ids:
batch.delete_item(Key={
"collectionId": collection_id,
"id": row["id"],
})
deleted += 1
cover_key = row.get("coverKey")
if cover_bucket and cover_key:
try:
_s3.delete_object(Bucket=cover_bucket, Key=cover_key)
covers_deleted += 1
except ClientError as exc:
logger.warning("Could not delete cover %s: %s", cover_key, exc)
if "LastEvaluatedKey" not in response:
break
response = table.query(
KeyConditionExpression=boto3.dynamodb.conditions.Key("collectionId").eq(collection_id),
ProjectionExpression="id, coverKey",
ExclusiveStartKey=response["LastEvaluatedKey"],
)
result = {
"collectionId": collection_id,
"upserted": len(seen_ids),
"coversStored": len(cover_keys),
"deleted": deleted,
"coversDeleted": covers_deleted,
"dryRun": dry_run,
}
logger.info("Sync complete: %s", result)
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Sync Raindrop.io collection to DynamoDB (with covers)")
parser.add_argument("--collection-id", type=int, default=None,
help="Override RAINDROP_COLLECTION_ID")
parser.add_argument("--profile", type=str, default=None,
help="AWS profile to use (local testing)")
parser.add_argument("--region", type=str, default=None,
help="AWS region to use (local testing)")
parser.add_argument("--dry-run", action="store_true",
help="Fetch and report only; skip DynamoDB writes and deletes")
parser.add_argument("--no-covers", action="store_true",
help="Skip cover image downloads entirely")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
if args.collection_id:
os.environ["RAINDROP_COLLECTION_ID"] = str(args.collection_id)
if args.no_covers:
os.environ.pop("COVER_BUCKET", None)
# Binds boto3 clients early (honours --profile/--region for local runs)
init_clients(profile_name=args.profile, region=args.region)
# event carries the dry_run flag through to the handler
exit_code = 0
try:
result = lambda_handler({"dry_run": args.dry_run}, None)
print(json.dumps(result, indent=2))
except Exception:
logger.exception("Sync failed")
exit_code = 1
sys.exit(exit_code)Sync Lambda Deployment with SAM
Deploying this Lambda using a CloudFormation SAM template is relatively straightforward - create the DynamoDB table, the Lambda and a managed policy that lets the Lambda inspect its nightly schedule:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Parameters:
TableName:
Type: String
Description: DynamoDB table name
CollectionId:
Type: Number
Description: Raindrop.io collection to sync
BookmarkCoverBucketName:
Type: String
Description: S3 bucket name for bookmark cover images
TokenParameterName:
Type: String
Default: /maceage-blog/raindrop-sync/bearer-token
Description: SSM parameter name for the bearer token
ScheduleState:
Type: String
Default: DISABLED
AllowedValues: [ENABLED, DISABLED]
Description: Whether the nightly sync schedule is active
Resources:
RaindropTable:
Type: AWS::DynamoDB::Table
UpdateReplacePolicy: Retain
DeletionPolicy: Delete
Properties:
TableName: !Ref TableName
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: collectionId
AttributeType: N
- AttributeName: id
AttributeType: N
KeySchema:
- AttributeName: collectionId
KeyType: HASH
- AttributeName: id
KeyType: RANGE
SSESpecification:
SSEEnabled: true
RaindropSyncLambda:
Type: AWS::Serverless::Function
Properties:
FunctionName: raindrop-sync
Description: Syncs a Raindrop.io collection to DynamoDB and covers to S3, deleting removed bookmarks and covers
Runtime: python3.14
Handler: raindrop_sync.lambda_handler
Timeout: 900
MemorySize: 256
CodeUri: ../raindrop-sync/
Architectures: [arm64]
Policies:
- Statement:
- Sid: ReadBearerToken
Effect: Allow
Action: ssm:GetParameter
Resource: !Sub
- arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${ParamName}
- ParamName: !Ref TokenParameterName
- Statement:
- Sid: DecryptSecureString
Effect: Allow
Action: kms:Decrypt
Resource: !Sub arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:alias/aws/ssm
- Statement:
- Sid: SyncTable
Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:DeleteItem
- dynamodb:Query
- dynamodb:DescribeTable
- dynamodb:BatchWriteItem
Resource: !GetAtt RaindropTable.Arn
- Statement:
- Sid: ManageCoverObjects
Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
Resource: !Sub arn:${AWS::Partition}:s3:::${BookmarkCoverBucketName}/*
- Statement:
- Sid: CreateLogGroup
Effect: Allow
Action: logs:CreateLogGroup
Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/raindrop-sync
- Statement:
- Sid: PutLogEvents
Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/raindrop-sync:log-stream:raindrop-sync*
Environment:
Variables:
RAINDROP_COLLECTION_ID: !Ref CollectionId
SSM_TOKEN_PARAMETER: !Ref TokenParameterName
DDB_TABLE_NAME: !Ref TableName
Events:
NightlySync:
Type: ScheduleV2
Properties:
State: !Ref ScheduleState
# 17:00 UTC = 04:00/05:00 AEST/AEDT
ScheduleExpression: cron(0 17 * * ? *)
SyncIntrospectionPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
Description: Lets the sync Lambda inspect itself and its nightly schedule
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: GetFunction
Effect: Allow
Action: lambda:GetFunction
Resource: !GetAtt RaindropSyncLambda.Arn
- Sid: GetSchedule
Effect: Allow
Action: scheduler:GetSchedule
Resource: !GetAtt RaindropSyncLambdaNightlySync.Arn
Roles:
- !Ref RaindropSyncLambdaRole
Outputs:
TableName:
Description: DynamoDB table holding synced raindrops
Value: !Ref RaindropTable
TableArn:
Description: DynamoDB table ARN
Value: !GetAtt RaindropTable.Arn
FunctionArn:
Description: Sync function ARN
Value: !GetAtt RaindropSyncLambda.Arn
TokenParameterName:
Description: SSM parameter holding the bearer token
Value: !Ref TokenParameterNameDeployment Workflow
Extending the existing GitHub Actions workflow for the blog was pretty straightforward - store the Raindrop.io bearer token, install AWS SAM, then validate, build and deploy the template:
...
- name: Store bearer token
run: aws ssm put-parameter --name "${{ env.TOKEN_PARAMETER_NAME }}" --value "${{ secrets.RAINDROP_BEARER_TOKEN }}" --type SecureString --overwrite
- name: Install AWS SAM
uses: aws-actions/setup-sam@v3
with:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: SAM validate
run: sam validate --template-file ${{ github.workspace }}/deploy/infra-raindrop-sync.yml
- name: SAM build
run: sam build -t ${{ github.workspace }}/deploy/infra-raindrop-sync.yml
- name: SAM deploy
run: |
sam deploy \
--template-file ${{ github.workspace }}/deploy/infra-raindrop-sync.yml \
--stack-name MaceageBlogSyncLambdaStack \
--s3-bucket ${{ env.LAMBDA_S3_BUCKET_NAME }} \
--s3-prefix raindrop-sync-lambda \
--force-upload \
--capabilities CAPABILITY_IAM \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides \
'ParameterKey=TableName,ParameterValue=${{ env.TABLE_NAME }}' \
'ParameterKey=CollectionId,ParameterValue=${{ secrets.RAINDROP_COLLECTION_ID }}' \
'ParameterKey=BookmarkCoverBucketName,ParameterValue=${{ env.BOOKMARK_COVER_BUCKET_NAME }}' \
'ParameterKey=TokenParameterName,ParameterValue=${{ env.TOKEN_PARAMETER_NAME }}' \
'ParameterKey=ScheduleState,ParameterValue=${{ env.SCHEDULE_STATE }}'
...IAM policy
The managed policy above breaks down to the following IAM policy document, which lets the sync Lambda inspect itself and its schedule:
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "lambda:GetFunction",
"Resource": "arn:aws:lambda:us-east-1:[AccountID]:function:raindrop-sync",
"Effect": "Allow",
"Sid": "GetFunction"
},
{
"Action": "scheduler:GetSchedule",
"Resource": "arn:aws:scheduler:us-east-1:[AccountID]:schedule/default/RaindropSyncLambdaNightlySync",
"Effect": "Allow",
"Sid": "GetSchedule"
}
]
}Wrapping up
With the sync Lambda successfully deployed, the next step was to test it and generate the Markdown pages for the bookmark data.
Related Posts
2026-01-09
2026-01-07



